From 4271d2ab17a50f82f652645e30370a318bf425f8 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:00:57 +0200 Subject: [PATCH] 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 --- apps/mobile/lib/matrix/timeline.dart | 7 +- apps/mobile/lib/screens/room_info_screen.dart | 11 + apps/mobile/lib/screens/room_screen.dart | 229 +---------- .../lib/screens/thread_list_screen.dart | 125 ++++++ apps/mobile/lib/screens/thread_screen.dart | 277 +++++++++++++ apps/mobile/lib/widgets/composer.dart | 307 +++++++++++++++ apps/mobile/lib/widgets/message.dart | 91 ++++- apps/mobile/lib/widgets/message_extras.dart | 365 ++++++++++++++++++ apps/mobile/pubspec.lock | 152 ++++++++ apps/mobile/pubspec.yaml | 3 + 10 files changed, 1349 insertions(+), 218 deletions(-) create mode 100644 apps/mobile/lib/screens/thread_list_screen.dart create mode 100644 apps/mobile/lib/screens/thread_screen.dart create mode 100644 apps/mobile/lib/widgets/composer.dart create mode 100644 apps/mobile/lib/widgets/message_extras.dart diff --git a/apps/mobile/lib/matrix/timeline.dart b/apps/mobile/lib/matrix/timeline.dart index 210018e..5df4ae0 100644 --- a/apps/mobile/lib/matrix/timeline.dart +++ b/apps/mobile/lib/matrix/timeline.dart @@ -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: diff --git a/apps/mobile/lib/screens/room_info_screen.dart b/apps/mobile/lib/screens/room_info_screen.dart index add85a2..0b08d76 100644 --- a/apps/mobile/lib/screens/room_info_screen.dart +++ b/apps/mobile/lib/screens/room_info_screen.dart @@ -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 { ) : 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'), diff --git a/apps/mobile/lib/screens/room_screen.dart b/apps/mobile/lib/screens/room_screen.dart index 5b2ef7f..fe58c1a 100644 --- a/apps/mobile/lib/screens/room_screen.dart +++ b/apps/mobile/lib/screens/room_screen.dart @@ -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 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 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 } _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 }); } - 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 _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 _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 _attach() async { - final choice = await showModalBottomSheet( - 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 _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 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; diff --git a/apps/mobile/lib/screens/thread_list_screen.dart b/apps/mobile/lib/screens/thread_list_screen.dart new file mode 100644 index 0000000..991b403 --- /dev/null +++ b/apps/mobile/lib/screens/thread_list_screen.dart @@ -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 createState() => _ThreadListScreenState(); +} + +class _ThreadListScreenState extends ConsumerState { + late Future> _threads = _load(); + + Future> _load() async { + final client = ref.read(clientProvider); + final room = client.getRoomById(widget.roomId)!; + final response = await client.getThreadRoots(room.id, limit: 50); + final roots = []; + 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>( + 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('m.relations') + ?.tryGetMap(RelationshipTypes.thread); + final count = summary?.tryGet('count') ?? 0; + final latestTs = summary + ?.tryGetMap('latest_event') + ?.tryGet('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)), + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/lib/screens/thread_screen.dart b/apps/mobile/lib/screens/thread_screen.dart new file mode 100644 index 0000000..07f6189 --- /dev/null +++ b/apps/mobile/lib/screens/thread_screen.dart @@ -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 route(String roomId, String rootId) => MaterialPageRoute( + settings: RouteSettings(name: 'thread/$roomId/$rootId'), + builder: (_) => ThreadScreen(roomId: roomId, rootId: rootId), + ); + + @override + ConsumerState createState() => _ThreadScreenState(); +} + +class _ThreadScreenState extends ConsumerState { + Timeline? _timeline; + Event? _root; + Object? _error; + + /// Replies fetched from the server, by event ID. + final _fetched = {}; + 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 _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 _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 _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 _replies() { + final timeline = _timeline; + final live = { + for (final e in timeline?.events ?? []) + 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(), + ); +} diff --git a/apps/mobile/lib/widgets/composer.dart b/apps/mobile/lib/widgets/composer.dart new file mode 100644 index 0000000..0293d94 --- /dev/null +++ b/apps/mobile/lib/widgets/composer.dart @@ -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 createState() => _ComposerState(); +} + +class _ComposerState extends State { + 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 _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 _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 _attach() async { + final choice = await showModalBottomSheet( + 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), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/widgets/message.dart b/apps/mobile/lib/widgets/message.dart index 4d9fdc7..3e80bdb 100644 --- a/apps/mobile/lib/widgets/message.dart +++ b/apps/mobile/lib/widgets/message.dart @@ -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? onReply; + + /// Opens the thread this message starts (or would start). Null inside a + /// thread, where there is no thread to open. + final ValueChanged? 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( + '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( 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': diff --git a/apps/mobile/lib/widgets/message_extras.dart b/apps/mobile/lib/widgets/message_extras.dart new file mode 100644 index 0000000..a1ca83f --- /dev/null +++ b/apps/mobile/lib/widgets/message_extras.dart @@ -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('m.relates_to') + ?.tryGet('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 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 askReaction(BuildContext context) { + final controller = TextEditingController(); + return showDialog( + 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 = >{}; + 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 list) { + showModalBottomSheet( + 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( + 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('m.relations') + ?.tryGetMap(RelationshipTypes.thread) + ?.tryGet('count') ?? + 0; + final loaded = root + .aggregatedEvents(timeline, RelationshipTypes.thread) + .length; + return bundled > loaded ? bundled : loaded; +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 922d487..2d6f0da 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -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: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index fa3517d..114e1db 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -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: