Mobile: replies, reactions and threads

- Replies: Reply on a message's long press puts it above the composer;
  sent replies quote the message they answer (sender and first line),
  with the reply fallback left out of the body.
- Reactions: six quick ones and any other on long press, chips under the
  message with counts, the user's own highlighted; tapping a chip adds or
  takes back a reaction, a long press lists who reacted.
- Threads: Reply in thread opens a thread view (the root, then its
  replies, older ones fetched from /relations, new ones live from the room
  timeline) with a composer that sends into the thread. Thread replies
  are hidden from the main timeline; the root shows "N replies". Room info
  lists the room's threads (/threads).

The composer moved out of the room screen into widgets/composer.dart so
the room and thread views share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 23:00:57 +02:00
1 parent 3356089c13
commit 4271d2ab17
10 files changed
+1349 -218

No files matched your search

+6 -1
View File
@@ -27,8 +27,13 @@ const noticeStateTypes = {
EventTypes.RoomTombstone,
};
EntryKind kindOf(Event event) {
/// [inThread] is set for a thread's own view; in the room's main timeline
/// a thread's replies are hidden behind the "N replies" under its root.
EntryKind kindOf(Event event, {bool inThread = false}) {
if (event.relationshipType == RelationshipTypes.edit) return EntryKind.hidden;
if (!inThread && event.relationshipType == RelationshipTypes.thread) {
return EntryKind.hidden;
}
switch (event.type) {
case EventTypes.Message:
case EventTypes.Sticker:
@@ -7,6 +7,7 @@ import '../state.dart';
import '../theme.dart';
import '../widgets/avatar.dart';
import '../widgets/verification_sheet.dart';
import 'thread_list_screen.dart';
class RoomInfoScreen extends ConsumerStatefulWidget {
const RoomInfoScreen({super.key, required this.roomId});
@@ -213,6 +214,16 @@ class _RoomInfoScreenState extends ConsumerState<RoomInfoScreen> {
)
: null,
),
ListTile(
leading: const Icon(Icons.forum_outlined),
title: const Text('Threads'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ThreadListScreen(roomId: room.id),
),
),
),
ListTile(
leading: const Icon(Icons.notifications_outlined),
title: const Text('Notifications'),
+13 -216
View File
@@ -1,10 +1,7 @@
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
@@ -14,8 +11,10 @@ import '../state.dart';
import '../theme.dart';
import '../util/format.dart';
import '../widgets/avatar.dart';
import '../widgets/composer.dart';
import '../widgets/message.dart';
import 'room_info_screen.dart';
import 'thread_screen.dart';
class RoomScreen extends ConsumerStatefulWidget {
const RoomScreen({super.key, required this.roomId});
@@ -35,13 +34,12 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
Timeline? _timeline;
Object? _loadError;
final _scroll = ScrollController();
final _composer = TextEditingController();
final _focus = FocusNode();
bool _loadingHistory = false;
Timer? _typingTimer;
bool _typing = false;
StreamSubscription? _syncSub;
/// The message the user is replying to.
Event? _replyTo;
Room? get _room => ref.read(clientProvider).getRoomById(widget.roomId);
@override
@@ -51,7 +49,6 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
Notifications.activeRoomId = widget.roomId;
Notifications.clearRoom(widget.roomId);
_scroll.addListener(_onScroll);
_composer.addListener(_onComposerChanged);
_syncSub = ref.read(clientProvider).onSync.stream.listen((_) {
if (mounted) setState(() {});
});
@@ -87,11 +84,7 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
}
_timeline?.cancelSubscriptions();
_syncSub?.cancel();
_typingTimer?.cancel();
if (_typing) _room?.setTyping(false);
_scroll.dispose();
_composer.dispose();
_focus.dispose();
super.dispose();
}
@@ -144,123 +137,6 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
});
}
void _onComposerChanged() {
final room = _room;
if (room == null) return;
final hasText = _composer.text.trim().isNotEmpty;
if (hasText && !_typing) {
_typing = true;
room.setTyping(true, timeout: 30000).catchError((_) {});
}
_typingTimer?.cancel();
if (hasText) {
_typingTimer = Timer(const Duration(seconds: 5), _stopTyping);
} else {
_stopTyping();
}
setState(() {});
}
void _stopTyping() {
if (!_typing) return;
_typing = false;
_room?.setTyping(false).catchError((_) {});
}
Future<void> _send() async {
final room = _room;
final text = _composer.text.trim();
if (room == null || text.isEmpty) return;
_composer.clear();
_stopTyping();
try {
// The pending message shows at once as "Sending"; a failure leaves it
// in the timeline with Retry and Delete.
await room.sendTextEvent(text);
} catch (e) {
_snack(describeError(e));
}
}
Future<void> _sendFile(MatrixFile file) async {
final room = _room;
if (room == null) return;
try {
await room.sendFileEvent(
file,
shrinkImageMaxDimension: 1600,
extraContent: {'body': file.name},
);
} catch (e) {
_snack(describeError(e));
}
}
Future<void> _attach() async {
final choice = await showModalBottomSheet<String>(
context: context,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Photo or video'),
onTap: () => Navigator.pop(sheet, 'gallery'),
),
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Take a photo'),
onTap: () => Navigator.pop(sheet, 'camera'),
),
ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: const Text('File'),
onTap: () => Navigator.pop(sheet, 'file'),
),
const SizedBox(height: 8),
],
),
),
);
if (choice == null) return;
try {
if (choice == 'file') {
for (final f in await FilePicker.pickFiles()) {
final bytes = await f.xFile.readAsBytes();
await _sendFile(MatrixFile.fromMimeType(bytes: bytes, name: f.name));
}
return;
}
final picker = ImagePicker();
final picked = choice == 'camera'
? [
?await picker.pickImage(
source: ImageSource.camera,
imageQuality: 90,
),
]
: await picker.pickMultipleMedia(imageQuality: 90);
for (final x in picked) {
final bytes = await x.readAsBytes();
await _sendFile(
MatrixFile.fromMimeType(
bytes: bytes,
name: x.name,
mimeType: x.mimeType,
),
);
}
} on PlatformException catch (e) {
_snack(e.message ?? "Couldn't open that.");
}
}
void _snack(String text) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
@override
Widget build(BuildContext context) {
final room = _room;
@@ -279,12 +155,10 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
_InviteBar(room: room)
else if (room.membership == Membership.join)
room.canSendDefaultMessages
? _Composer(
controller: _composer,
focus: _focus,
encrypted: room.encrypted,
onSend: _send,
onAttach: _attach,
? Composer(
room: room,
replyTo: _replyTo,
onCancelReply: () => setState(() => _replyTo = null),
)
: const _ReadOnlyBar(),
],
@@ -336,6 +210,10 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
readByOthers: readUpTo != null && index >= readUpTo,
onReply: (e) => setState(() => _replyTo = e),
onOpenThread: (e) => Navigator.of(
context,
).push(ThreadScreen.route(room.id, e.eventId)),
),
};
},
@@ -540,87 +418,6 @@ class _TypingRow extends StatelessWidget {
}
}
class _Composer extends StatelessWidget {
const _Composer({
required this.controller,
required this.focus,
required this.encrypted,
required this.onSend,
required this.onAttach,
});
final TextEditingController controller;
final FocusNode focus;
final bool encrypted;
final VoidCallback onSend;
final VoidCallback onAttach;
@override
Widget build(BuildContext context) {
final c = context.warp;
final canSend = controller.text.trim().isNotEmpty;
return Container(
decoration: BoxDecoration(
color: c.surface,
border: Border(top: BorderSide(color: c.line)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 8, 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
tooltip: 'Attach',
icon: const Icon(Icons.add_circle_outline),
color: c.inkMuted,
onPressed: onAttach,
),
Expanded(
child: TextField(
controller: controller,
focusNode: focus,
minLines: 1,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
keyboardType: TextInputType.multiline,
decoration: InputDecoration(
hintText: encrypted ? 'Encrypted message' : 'Message',
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(color: c.tealStrong),
),
),
),
),
const SizedBox(width: 6),
IconButton.filled(
tooltip: 'Send',
onPressed: canSend ? onSend : null,
icon: const Icon(Icons.send_rounded),
),
],
),
),
),
);
}
}
class _InviteBar extends StatefulWidget {
const _InviteBar({required this.room});
final Room room;
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../state.dart';
import '../util/format.dart';
import '../widgets/avatar.dart';
import '../widgets/message_extras.dart';
import 'thread_screen.dart';
/// A room's threads, the most recently active first (`/threads`).
class ThreadListScreen extends ConsumerStatefulWidget {
const ThreadListScreen({super.key, required this.roomId});
final String roomId;
@override
ConsumerState<ThreadListScreen> createState() => _ThreadListScreenState();
}
class _ThreadListScreenState extends ConsumerState<ThreadListScreen> {
late Future<List<Event>> _threads = _load();
Future<List<Event>> _load() async {
final client = ref.read(clientProvider);
final room = client.getRoomById(widget.roomId)!;
final response = await client.getThreadRoots(room.id, limit: 50);
final roots = <Event>[];
for (final m in response.chunk) {
var event = Event.fromMatrixEvent(m, room);
final encryption = client.encryption;
if (event.type == EventTypes.Encrypted && encryption != null) {
try {
event = await encryption.decryptRoomEvent(event);
} catch (_) {}
}
roots.add(event);
}
return roots;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Threads')),
body: RefreshIndicator(
onRefresh: () async {
setState(() => _threads = _load());
await _threads;
},
child: FutureBuilder<List<Event>>(
future: _threads,
builder: (context, snapshot) {
if (snapshot.hasError) {
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Text(describeError(snapshot.error!)),
),
],
);
}
final threads = snapshot.data;
if (threads == null) {
return const Center(child: CircularProgressIndicator());
}
if (threads.isEmpty) {
return ListView(
children: const [
Padding(
padding: EdgeInsets.all(32),
child: Text(
'No threads yet. Long-press a message and choose '
'Reply in thread to start one.',
textAlign: TextAlign.center,
),
),
],
);
}
return ListView.separated(
itemCount: threads.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final root = threads[i];
final sender = root.senderFromMemoryOrFallback;
final summary = root.unsigned
?.tryGetMap<String, Object?>('m.relations')
?.tryGetMap<String, Object?>(RelationshipTypes.thread);
final count = summary?.tryGet<int>('count') ?? 0;
final latestTs = summary
?.tryGetMap<String, Object?>('latest_event')
?.tryGet<int>('origin_server_ts');
final when = latestTs == null
? root.originServerTs
: DateTime.fromMillisecondsSinceEpoch(latestTs);
return ListTile(
leading: Avatar(
name: sender.calcDisplayname(),
seed: sender.id,
mxc: sender.avatarUrl,
),
title: Text(
quoteText(root),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${sender.calcDisplayname()} · '
'${count == 1 ? '1 reply' : '$count replies'} · '
'${formatListTime(when)}',
),
onTap: () => Navigator.of(
context,
).push(ThreadScreen.route(widget.roomId, root.eventId)),
);
},
);
},
),
),
);
}
}
+277
View File
@@ -0,0 +1,277 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../matrix/timeline.dart';
import '../state.dart';
import '../widgets/composer.dart';
import '../widgets/message.dart';
/// A thread: its root, then its replies, with a composer that replies in
/// the thread.
///
/// The SDK has no thread timeline, so this keeps the room's own [Timeline]
/// (which receives new replies, their sending status, edits and reactions
/// like any event) and adds the older replies it doesn't have from
/// `/relations`.
class ThreadScreen extends ConsumerStatefulWidget {
const ThreadScreen({super.key, required this.roomId, required this.rootId});
final String roomId;
final String rootId;
static Route<void> route(String roomId, String rootId) => MaterialPageRoute(
settings: RouteSettings(name: 'thread/$roomId/$rootId'),
builder: (_) => ThreadScreen(roomId: roomId, rootId: rootId),
);
@override
ConsumerState<ThreadScreen> createState() => _ThreadScreenState();
}
class _ThreadScreenState extends ConsumerState<ThreadScreen> {
Timeline? _timeline;
Event? _root;
Object? _error;
/// Replies fetched from the server, by event ID.
final _fetched = <String, Event>{};
String? _nextBatch;
bool _loadingOlder = false;
bool _reachedStart = false;
Event? _replyTo;
final _scroll = ScrollController();
Room get _room => ref.read(clientProvider).getRoomById(widget.roomId)!;
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
_load();
}
@override
void dispose() {
_timeline?.cancelSubscriptions();
_scroll.dispose();
super.dispose();
}
Future<void> _load() async {
final room = _room;
try {
final timeline = await room.getTimeline(
onUpdate: () {
if (mounted) setState(() {});
},
);
final root =
await timeline.getEventById(widget.rootId) ??
await room.getEventById(widget.rootId);
if (!mounted) {
timeline.cancelSubscriptions();
return;
}
setState(() {
_timeline = timeline;
_root = root;
});
await _loadOlder();
} catch (e) {
if (mounted) setState(() => _error = e);
}
}
Future<Event> _decrypt(Event event) async {
final encryption = event.room.client.encryption;
if (event.type != EventTypes.Encrypted || encryption == null) return event;
try {
return await encryption.decryptRoomEvent(event);
} catch (_) {
return event;
}
}
/// Fetches the next page of older replies.
Future<void> _loadOlder() async {
if (_loadingOlder || _reachedStart) return;
final room = _room;
setState(() => _loadingOlder = true);
try {
final response = await room.client.getRelatingEventsWithRelType(
room.id,
widget.rootId,
RelationshipTypes.thread,
from: _nextBatch,
dir: Direction.b,
limit: 30,
);
for (final m in response.chunk) {
final event = await _decrypt(Event.fromMatrixEvent(m, room));
_fetched[event.eventId] = event;
}
final root = _root;
if (root != null && root.type == EventTypes.Encrypted) {
_root = await _decrypt(root);
}
_nextBatch = response.nextBatch;
_reachedStart = response.nextBatch == null;
} catch (e) {
Logs().w('Could not load the thread', e);
_reachedStart = true;
} finally {
if (mounted) setState(() => _loadingOlder = false);
}
}
void _onScroll() {
if (!_scroll.hasClients) return;
final position = _scroll.position;
if (position.pixels > position.maxScrollExtent - 400) _loadOlder();
}
/// The replies, newest first: the room timeline's (live) and the fetched
/// ones it doesn't have.
List<Event> _replies() {
final timeline = _timeline;
final live = <String, Event>{
for (final e in timeline?.events ?? <Event>[])
if (e.relationshipType == RelationshipTypes.thread &&
e.relationshipEventId == widget.rootId)
e.eventId: e,
};
final all = [
...live.values,
for (final e in _fetched.values)
if (!live.containsKey(e.eventId)) e,
];
all.sort((a, b) {
// Pending messages are the newest.
if (!a.status.isSynced && b.status.isSynced) return -1;
if (a.status.isSynced && !b.status.isSynced) return 1;
return b.originServerTs.compareTo(a.originServerTs);
});
return all;
}
@override
Widget build(BuildContext context) {
ref.watch(syncProvider);
final room = ref.watch(clientProvider).getRoomById(widget.roomId);
final timeline = _timeline;
final root = _root;
if (room == null) return Scaffold(appBar: AppBar());
Widget body;
if (_error != null) {
body = Center(child: Text(describeError(_error!)));
} else if (timeline == null) {
body = const Center(child: CircularProgressIndicator());
} else {
final replies = _replies();
final entries = [...replies, ?root];
final items = projectTimeline([
for (final e in entries)
(
sender: e.senderId,
time: e.originServerTs,
kind: kindOf(e, inThread: true),
),
]);
body = ListView.builder(
controller: _scroll,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length + 1,
itemBuilder: (context, i) {
if (i == items.length) {
return _loadingOlder
? const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
)
: root == null
? const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text(
"The thread's first message isn't available.",
),
),
)
: const SizedBox(height: 8);
}
final item = items[i];
return switch (item) {
DayItem() => const SizedBox(height: 8),
EventItem(:final index, :final kind)
when kind == EntryKind.notice =>
NoticeRow(event: entries[index]),
EventItem(:final index, :final firstInGroup, :final lastInGroup) =>
Column(
children: [
MessageRow(
key: ValueKey(entries[index].eventId),
event: entries[index],
timeline: timeline,
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
readByOthers: false,
onReply: (e) => setState(() => _replyTo = e),
),
if (entries[index].eventId == widget.rootId)
const _ThreadDivider(),
],
),
};
},
);
}
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Thread'),
Text(
room.getLocalizedDisplayname(),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
body: Column(
children: [
Expanded(child: body),
if (room.membership == Membership.join && room.canSendDefaultMessages)
Composer(
room: room,
replyTo: _replyTo,
onCancelReply: () => setState(() => _replyTo = null),
target: SendTarget(
threadRootId: widget.rootId,
threadLastEventId: () =>
_replies()
.where((e) => e.status.isSynced)
.firstOrNull
?.eventId ??
widget.rootId,
),
),
],
),
);
}
}
class _ThreadDivider extends StatelessWidget {
const _ThreadDivider();
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Divider(),
);
}
+307
View File
@@ -0,0 +1,307 @@
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../theme.dart';
import 'message_extras.dart';
/// Where a message goes: the room's main timeline, or a thread in it.
class SendTarget {
const SendTarget({this.threadRootId, this.threadLastEventId});
/// The thread's root, when sending into a thread.
final String? threadRootId;
/// The newest event in the thread: what a client that doesn't know
/// threads shows the message as a reply to.
final String? Function()? threadLastEventId;
}
/// The message box: text, attachments, typing notifications, and the
/// message being replied to, shown above it until sent or dismissed.
class Composer extends StatefulWidget {
const Composer({
super.key,
required this.room,
this.target = const SendTarget(),
this.replyTo,
this.onCancelReply,
this.focus,
});
final Room room;
final SendTarget target;
/// The message being answered, or null.
final Event? replyTo;
final VoidCallback? onCancelReply;
final FocusNode? focus;
@override
State<Composer> createState() => _ComposerState();
}
class _ComposerState extends State<Composer> {
final _controller = TextEditingController();
late final FocusNode _focus = widget.focus ?? FocusNode();
Timer? _typingTimer;
bool _typing = false;
Room get _room => widget.room;
@override
void initState() {
super.initState();
_controller.addListener(_onChanged);
}
@override
void didUpdateWidget(Composer old) {
super.didUpdateWidget(old);
// Choosing a message to reply to puts the cursor in the box.
if (widget.replyTo != null && widget.replyTo != old.replyTo) {
_focus.requestFocus();
}
}
@override
void dispose() {
_typingTimer?.cancel();
if (_typing) _room.setTyping(false).catchError((_) {});
_controller.dispose();
if (widget.focus == null) _focus.dispose();
super.dispose();
}
void _onChanged() {
final hasText = _controller.text.trim().isNotEmpty;
if (hasText && !_typing) {
_typing = true;
_room.setTyping(true, timeout: 30000).catchError((_) {});
}
_typingTimer?.cancel();
if (hasText) {
_typingTimer = Timer(const Duration(seconds: 5), _stopTyping);
} else {
_stopTyping();
}
setState(() {});
}
void _stopTyping() {
if (!_typing) return;
_typing = false;
_room.setTyping(false).catchError((_) {});
}
Future<void> _send() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
final replyTo = widget.replyTo;
_controller.clear();
_stopTyping();
widget.onCancelReply?.call();
try {
// The pending message shows at once as "Sending"; a failure leaves it
// in the timeline with Retry and Delete.
await _room.sendTextEvent(
text,
inReplyTo: replyTo,
threadRootEventId: widget.target.threadRootId,
threadLastEventId: widget.target.threadLastEventId?.call(),
);
} catch (e) {
_snack(describeError(e));
}
}
Future<void> _sendFile(MatrixFile file, Event? replyTo) async {
try {
await _room.sendFileEvent(
file,
inReplyTo: replyTo,
threadRootEventId: widget.target.threadRootId,
threadLastEventId: widget.target.threadLastEventId?.call(),
shrinkImageMaxDimension: 1600,
extraContent: {'body': file.name},
);
} catch (e) {
_snack(describeError(e));
}
}
Future<void> _attach() async {
final choice = await showModalBottomSheet<String>(
context: context,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Photo or video'),
onTap: () => Navigator.pop(sheet, 'gallery'),
),
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Take a photo'),
onTap: () => Navigator.pop(sheet, 'camera'),
),
ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: const Text('File'),
onTap: () => Navigator.pop(sheet, 'file'),
),
const SizedBox(height: 8),
],
),
),
);
if (choice == null) return;
final replyTo = widget.replyTo;
widget.onCancelReply?.call();
try {
if (choice == 'file') {
for (final f in await FilePicker.pickFiles()) {
final bytes = await f.xFile.readAsBytes();
await _sendFile(
MatrixFile.fromMimeType(bytes: bytes, name: f.name),
replyTo,
);
}
return;
}
final picker = ImagePicker();
final picked = choice == 'camera'
? [
?await picker.pickImage(
source: ImageSource.camera,
imageQuality: 90,
),
]
: await picker.pickMultipleMedia(imageQuality: 90);
for (final x in picked) {
final bytes = await x.readAsBytes();
await _sendFile(
MatrixFile.fromMimeType(
bytes: bytes,
name: x.name,
mimeType: x.mimeType,
),
replyTo,
);
}
} on PlatformException catch (e) {
_snack(e.message ?? "Couldn't open that.");
}
}
void _snack(String text) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
@override
Widget build(BuildContext context) {
final c = context.warp;
final canSend = _controller.text.trim().isNotEmpty;
final replyTo = widget.replyTo;
final inThread = widget.target.threadRootId != null;
final hint = inThread
? 'Reply in thread'
: _room.encrypted
? 'Encrypted message'
: 'Message';
return Container(
decoration: BoxDecoration(
color: c.surface,
border: Border(top: BorderSide(color: c.line)),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (replyTo != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
child: Row(
children: [
Icon(Icons.reply, size: 18, color: c.tealText),
const SizedBox(width: 8),
Expanded(
child: QuoteBlock(
sender:
'Replying to ${replyTo.senderFromMemoryOrFallback.calcDisplayname()}',
text: quoteText(replyTo),
),
),
IconButton(
tooltip: 'Cancel reply',
icon: const Icon(Icons.close),
onPressed: widget.onCancelReply,
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 8, 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
tooltip: 'Attach',
icon: const Icon(Icons.add_circle_outline),
color: c.inkMuted,
onPressed: _attach,
),
Expanded(
child: TextField(
controller: _controller,
focusNode: _focus,
minLines: 1,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
keyboardType: TextInputType.multiline,
decoration: InputDecoration(
hintText: hint,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(color: c.tealStrong),
),
),
),
),
const SizedBox(width: 6),
IconButton.filled(
tooltip: 'Send',
onPressed: canSend ? _send : null,
icon: const Icon(Icons.send_rounded),
),
],
),
),
],
),
),
);
}
}
+90 -1
View File
@@ -10,6 +10,7 @@ import '../matrix/media.dart';
import '../theme.dart';
import '../util/format.dart';
import 'avatar.dart';
import 'message_extras.dart';
const _i18n = MatrixDefaultLocalizations();
@@ -44,6 +45,8 @@ class MessageRow extends StatelessWidget {
required this.firstInGroup,
required this.lastInGroup,
required this.readByOthers,
this.onReply,
this.onOpenThread,
});
final Event event;
@@ -52,6 +55,13 @@ class MessageRow extends StatelessWidget {
final bool lastInGroup;
final bool readByOthers;
/// Starts a reply to this message in the composer.
final ValueChanged<Event>? onReply;
/// Opens the thread this message starts (or would start). Null inside a
/// thread, where there is no thread to open.
final ValueChanged<Event>? onOpenThread;
bool get _mine => event.senderId == event.room.client.userID;
@override
@@ -76,7 +86,30 @@ class MessageRow extends StatelessWidget {
bottomRight: _mine && !lastInGroup ? tight : radius,
);
final body = _MessageBody(event: display, original: event, mine: _mine);
final replyTo = event.inReplyToEventId(includingFallback: false);
// A thread reply's in-reply-to is the thread's fallback: not shown.
final isThreadFallback =
event.relationshipType == RelationshipTypes.thread &&
event.content.tryGetMap<String, Object?>(
'm.relates_to',
)?['is_falling_back'] ==
true;
final content = _MessageBody(event: display, original: event, mine: _mine);
final body = replyTo == null || display.redacted || isThreadFallback
? content
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: isMedia
? const EdgeInsets.all(6)
: const EdgeInsets.only(bottom: 6),
child: ReplyPreview(event: event, timeline: timeline),
),
content,
],
);
final bubble = GestureDetector(
onLongPress: () => _showActions(context, display),
child: isMedia
@@ -136,6 +169,13 @@ class MessageRow extends StatelessWidget {
),
),
bubble,
Reactions(event: event, timeline: timeline),
if (onOpenThread != null)
ThreadSummary(
event: event,
timeline: timeline,
onTap: () => onOpenThread!(event),
),
if (lastInGroup || event.status.isError)
Padding(
padding: const EdgeInsets.only(top: 3, left: 4, right: 4),
@@ -171,6 +211,10 @@ class MessageRow extends StatelessWidget {
event.canRedact &&
event.room.canRedact;
final failed = event.status.isError;
final canRespond =
event.status.isSent &&
!event.redacted &&
event.room.canSendDefaultMessages;
final action = await showModalBottomSheet<String>(
context: context,
@@ -178,6 +222,40 @@ class MessageRow extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (canRespond && event.room.canSendEvent(EventTypes.Reaction))
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (final emoji in quickReactions)
ReactionButton(
emoji: emoji,
selected: myReaction(event, timeline, emoji) != null,
onTap: () => Navigator.pop(sheet, 'react:$emoji'),
),
IconButton(
tooltip: 'Other reaction',
icon: const Icon(Icons.add_reaction_outlined),
onPressed: () => Navigator.pop(sheet, 'react-other'),
),
],
),
),
if (canRespond && onReply != null)
ListTile(
leading: const Icon(Icons.reply),
title: const Text('Reply'),
onTap: () => Navigator.pop(sheet, 'reply'),
),
if (canRespond &&
onOpenThread != null &&
event.relationshipType != RelationshipTypes.thread)
ListTile(
leading: const Icon(Icons.forum_outlined),
title: const Text('Reply in thread'),
onTap: () => Navigator.pop(sheet, 'thread'),
),
if (failed) ...[
ListTile(
leading: const Icon(Icons.refresh),
@@ -218,7 +296,18 @@ class MessageRow extends StatelessWidget {
);
if (!context.mounted) return;
try {
if (action != null && action.startsWith('react:')) {
await toggleReaction(event, timeline, action.substring(6));
return;
}
switch (action) {
case 'react-other':
final emoji = await askReaction(context);
if (emoji != null) await toggleReaction(event, timeline, emoji);
case 'reply':
onReply?.call(event);
case 'thread':
onOpenThread?.call(event);
case 'retry':
await event.sendAgain();
case 'cancel':
+365
View File
@@ -0,0 +1,365 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../theme.dart';
import 'avatar.dart';
const _i18n = MatrixDefaultLocalizations();
/// The reactions offered with one tap.
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
/// The key of a reaction event: the emoji (or text) it reacts with.
String? reactionKey(Event reaction) => reaction.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key');
/// The user's own reaction with [key] to [event], if any.
Event? myReaction(Event event, Timeline timeline, String key) => event
.aggregatedEvents(timeline, RelationshipTypes.reaction)
.where(
(r) =>
r.senderId == event.room.client.userID &&
!r.redacted &&
reactionKey(r) == key,
)
.firstOrNull;
/// Reacts to [event] with [key], or takes the reaction back if it's there.
Future<void> toggleReaction(Event event, Timeline timeline, String key) async {
final mine = myReaction(event, timeline, key);
if (mine != null) {
await mine.redactEvent();
} else {
await event.room.sendReaction(event.eventId, key);
}
}
Future<String?> askReaction(BuildContext context) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (dialog) => AlertDialog(
title: const Text('React with'),
content: TextField(
controller: controller,
autofocus: true,
maxLength: 16,
decoration: const InputDecoration(hintText: 'An emoji, or a word'),
onSubmitted: (v) =>
Navigator.pop(dialog, v.trim().isEmpty ? null : v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialog),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final v = controller.text.trim();
Navigator.pop(dialog, v.isEmpty ? null : v);
},
child: const Text('React'),
),
],
),
);
}
class ReactionButton extends StatelessWidget {
const ReactionButton({
super.key,
required this.emoji,
required this.selected,
required this.onTap,
});
final String emoji;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = context.warp;
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? c.tealSoft : c.surfaceSunken,
),
child: Text(emoji, style: const TextStyle(fontSize: 22)),
),
);
}
}
/// The reactions under a message, one chip per key with its count. Tapping
/// one adds or takes back the user's own; a long press says who reacted.
class Reactions extends StatelessWidget {
const Reactions({super.key, required this.event, required this.timeline});
final Event event;
final Timeline timeline;
@override
Widget build(BuildContext context) {
final reactions = event
.aggregatedEvents(timeline, RelationshipTypes.reaction)
.where((r) => !r.redacted && reactionKey(r) != null)
.toList();
if (reactions.isEmpty) return const SizedBox.shrink();
final me = event.room.client.userID;
final byKey = <String, List<Event>>{};
for (final r in reactions) {
byKey.putIfAbsent(reactionKey(r)!, () => []).add(r);
}
final keys = byKey.keys.toList()
..sort((a, b) => byKey[b]!.length.compareTo(byKey[a]!.length));
final c = context.warp;
final text = Theme.of(context).textTheme;
final canReact =
event.status.isSent && event.room.canSendEvent(EventTypes.Reaction);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
for (final key in keys)
_ReactionChip(
label: '$key ${byKey[key]!.length}',
mine: byKey[key]!.any((r) => r.senderId == me),
colours: c,
style: text.labelMedium,
onTap: !canReact
? null
: () => toggleReaction(event, timeline, key).catchError((
Object e,
) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(describeError(e))),
);
}
}),
onLongPress: () => _showReactors(context, key, byKey[key]!),
),
],
),
);
}
void _showReactors(BuildContext context, String key, List<Event> list) {
showModalBottomSheet<void>(
context: context,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(key, style: const TextStyle(fontSize: 32)),
for (final r in list)
ListTile(
leading: Avatar(
name: r.senderFromMemoryOrFallback.calcDisplayname(),
seed: r.senderId,
mxc: r.senderFromMemoryOrFallback.avatarUrl,
size: 32,
),
title: Text(r.senderFromMemoryOrFallback.calcDisplayname()),
),
const SizedBox(height: 8),
],
),
),
);
}
}
class _ReactionChip extends StatelessWidget {
const _ReactionChip({
required this.label,
required this.mine,
required this.colours,
required this.style,
required this.onTap,
required this.onLongPress,
});
final String label;
final bool mine;
final WarpColors colours;
final TextStyle? style;
final VoidCallback? onTap;
final VoidCallback onLongPress;
@override
Widget build(BuildContext context) {
final c = colours;
return Material(
color: mine ? c.tealSoft : c.surfaceSunken,
shape: StadiumBorder(
side: BorderSide(color: mine ? c.tealStrong : c.line),
),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
child: Text(
label,
style: style?.copyWith(color: mine ? c.tealText : c.ink),
),
),
),
);
}
}
/// The message a reply answers, quoted above the reply: who wrote it and
/// its first line.
class ReplyPreview extends StatelessWidget {
const ReplyPreview({super.key, required this.event, required this.timeline});
final Event event;
final Timeline timeline;
@override
Widget build(BuildContext context) {
return FutureBuilder<Event?>(
future: event.getReplyEvent(timeline),
builder: (context, snapshot) {
final original = snapshot.data;
return QuoteBlock(
sender: original?.senderFromMemoryOrFallback.calcDisplayname(),
text: original == null
? (snapshot.connectionState == ConnectionState.done
? 'Message not available'
: '…')
: quoteText(original.getDisplayEvent(timeline)),
);
},
);
}
}
/// One line describing a message, for a quote or the reply bar.
String quoteText(Event event) {
if (event.redacted) return 'Message deleted';
if (event.type == EventTypes.Encrypted) return 'Encrypted message';
return switch (event.messageType) {
MessageTypes.Image => '🖼 Image',
MessageTypes.Sticker => 'Sticker',
MessageTypes.Video => '🎬 Video',
MessageTypes.Audio => '🎵 Audio',
MessageTypes.File => '📄 ${event.body}',
_ =>
event.calcLocalizedBodyFallback(_i18n, hideReply: true).split('\n').first,
};
}
/// A quoted message: a teal bar, the sender, a line of text.
class QuoteBlock extends StatelessWidget {
const QuoteBlock({super.key, required this.sender, required this.text});
final String? sender;
final String text;
@override
Widget build(BuildContext context) {
final c = context.warp;
final style = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 4),
decoration: BoxDecoration(
color: c.surface.withValues(alpha: 0.6),
border: Border(left: BorderSide(color: c.tealStrong, width: 3)),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (sender != null)
Text(
sender!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.labelMedium?.copyWith(
color: c.tealText,
fontWeight: FontWeight.w600,
),
),
Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: style.bodySmall?.copyWith(color: c.inkMuted),
),
],
),
);
}
}
/// Under a message that starts a thread: how many replies, opening it.
class ThreadSummary extends StatelessWidget {
const ThreadSummary({
super.key,
required this.event,
required this.timeline,
required this.onTap,
});
final Event event;
final Timeline timeline;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final count = threadReplyCount(event, timeline);
if (count == 0) return const SizedBox.shrink();
final c = context.warp;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_outlined, size: 16, color: c.tealText),
const SizedBox(width: 4),
Text(
count == 1 ? '1 reply' : '$count replies',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: c.tealText,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}
/// How many replies a thread root has: the server's count from the event's
/// bundled relations, or the replies loaded here if that is more (some came
/// in since the root was fetched).
int threadReplyCount(Event root, Timeline timeline) {
final bundled =
root.unsigned
?.tryGetMap<String, Object?>('m.relations')
?.tryGetMap<String, Object?>(RelationshipTypes.thread)
?.tryGet<int>('count') ??
0;
final loaded = root
.aggregatedEvents(timeline, RelationshipTypes.thread)
.length;
return bundled > loaded ? bundled : loaded;
}
+152
View File
@@ -25,6 +25,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.7.0"
asn1lib:
dependency: transitive
description:
name: asn1lib
sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024"
url: "https://pub.dev"
source: hosted
version: "1.6.5"
async:
dependency: transitive
description:
@@ -129,6 +137,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: transitive
description:
name: connectivity_plus
sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0"
url: "https://pub.dev"
source: hosted
version: "7.3.1"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
@@ -153,6 +185,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
dart_jsonwebtoken:
dependency: transitive
description:
name: dart_jsonwebtoken
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
url: "https://pub.dev"
source: hosted
version: "3.4.1"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: "078e3c431500147e5cc52b3c6ea41ed538f30c7720cc2467d2186c9251e62716"
url: "https://pub.dev"
source: hosted
version: "1.8.2"
dbus:
dependency: transitive
description:
@@ -161,6 +209,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.15"
device_info_plus:
dependency: transitive
description:
name: device_info_plus
sha256: ade1c36f8355d6284c98e4a47992ea263887ed3d1acb281e37635dd45f04c4aa
url: "https://pub.dev"
source: hosted
version: "13.3.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
fake_async:
dependency: transitive
description:
@@ -424,6 +488,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: transitive
description:
name: flutter_webrtc
sha256: ebf5ceef2897caae425ed65a38b2961c29d9d9a626159418ed460e1a65038c00
url: "https://pub.dev"
source: hosted
version: "1.6.2+hotfix.4"
glob:
dependency: transitive
description:
@@ -624,6 +696,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
livekit_client:
dependency: "direct main"
description:
name: livekit_client
sha256: "1d2ae53abac63fdf8ac2c3f37529094b920048e5e459f29c1140b885d0cdf325"
url: "https://pub.dev"
source: hosted
version: "2.13.1"
logger:
dependency: transitive
description:
name: logger
sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7"
url: "https://pub.dev"
source: hosted
version: "2.8.0"
logging:
dependency: transitive
description:
@@ -680,6 +768,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
mime_type:
dependency: transitive
description:
name: mime_type
sha256: d652b613e84dac1af28030a9fba82c0999be05b98163f9e18a0849c6e63838bb
url: "https://pub.dev"
source: hosted
version: "1.0.1"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0"
url: "https://pub.dev"
source: hosted
version: "7.4.2"
native_toolchain_c:
dependency: transitive
description:
@@ -696,6 +800,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.7"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
objective_c:
dependency: transitive
description:
@@ -792,6 +904,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
posix:
dependency: transitive
description:
@@ -800,6 +920,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.5.2"
protobuf:
dependency: transitive
description:
name: protobuf
sha256: "13b6717366aa7ec3792dd62c19c7d33a785708224365f2db23c5ca01f1c1a0f2"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
pub_semver:
dependency: transitive
description:
@@ -808,6 +936,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
random_string:
dependency: transitive
description:
@@ -1205,6 +1349,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.4.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
window_manager:
dependency: transitive
description:
+3
View File
@@ -48,6 +48,9 @@ dependencies:
intl: ^0.20.3
url_launcher: ^6.3.3
mime: ^2.1.0
livekit_client: ^2.13.1
qr_flutter: ^4.1.0
mobile_scanner: ^7.4.2
dev_dependencies:
flutter_test: