diff --git a/lib/config/routes.dart b/lib/config/routes.dart index cc987a9..4ba6ab2 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:extera_next/pages/thread/thread.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -148,6 +149,21 @@ abstract class AppRoutes { ), ), redirect: loggedOutRedirect, + routes: [ + GoRoute( + path: ':threadroot', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ThreadPage( + roomId: state.pathParameters['roomid']!, + threadRootEventId: + state.pathParameters['threadroot']!, + eventId: state.uri.queryParameters['event'], + ), + ), + ), + ], ), ], redirect: loggedOutRedirect, @@ -352,6 +368,18 @@ abstract class AppRoutes { }, redirect: loggedOutRedirect, routes: [ + GoRoute( + path: ':threadroot', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ThreadPage( + roomId: state.pathParameters['roomid']!, + threadRootEventId: state.pathParameters['threadroot']!, + eventId: state.uri.queryParameters['event'], + ), + ), + ), GoRoute( path: 'search', pageBuilder: (context, state) => defaultPageBuilder( diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index bbb73e5..048c4d5 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -85,12 +85,14 @@ class ChatPage extends StatelessWidget { class ChatPageWithRoom extends StatefulWidget { final Room room; + final Thread? thread; final List? shareItems; final String? eventId; const ChatPageWithRoom({ super.key, required this.room, + this.thread, this.shareItems, this.eventId, }); @@ -102,14 +104,18 @@ class ChatPageWithRoom extends StatefulWidget { class ChatController extends State with WidgetsBindingObserver { Room get room => sendingClient.getRoomById(roomId) ?? widget.room; + Thread? get thread => + sendingClient.getRoomById(roomId)?.threads[threadRootEventId] ?? + widget.room.threads[threadRootEventId]; late Client sendingClient; - RoomTimeline? timeline; + Timeline? timeline; late final String readMarkerEventId; String get roomId => widget.room.id; + String? get threadRootEventId => widget.thread?.rootEvent.eventId; final AutoScrollController scrollController = AutoScrollController(); @@ -134,6 +140,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: details.files, room: room, + thread: thread, replyEvent: replyEvent, outerContext: context, ), @@ -274,6 +281,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: files, room: room, + thread: thread, outerContext: context, replyEvent: replyEvent, ), @@ -324,6 +332,7 @@ class ChatController extends State void _tryLoadTimeline() async { final initialEventId = widget.eventId; loadTimelineFuture = _getTimeline(); + Logs().v("Trying to load timeline..."); try { await loadTimelineFuture; if (initialEventId != null) scrollToEventId(initialEventId); @@ -387,15 +396,7 @@ class ChatController extends State animateInEventIndex = i; } - Future _getTimeline({ - String? eventContextId, - }) async { - await Matrix.of(context).client.roomsLoading; - await Matrix.of(context).client.accountDataLoading; - if (eventContextId != null && - (!eventContextId.isValidMatrixId || eventContextId.sigil != '\$')) { - eventContextId = null; - } + Future _loadRoomTimeline({String? eventContextId}) async { try { timeline?.cancelSubscriptions(); timeline = await room.getTimeline( @@ -415,6 +416,56 @@ class ChatController extends State _showScrollUpMaterialBanner(eventContextId!); } } + } + + Future _loadThreadTimeline({String? eventContextId}) async { + if (thread == null) { + throw Exception( + "_loadThreadTimeline should not be called, thread == null", + ); + } + try { + timeline?.cancelSubscriptions(); + timeline = await thread!.getTimeline( + onUpdate: updateView, + eventContextId: eventContextId, + onInsert: onInsert, + ); + if (timeline is ThreadTimeline) { + (timeline as ThreadTimeline).getThreadEvents(); + } + Logs().v("Thread timeline loaded"); + } catch (e, s) { + Logs().w( + 'Unable to load timeline on event ID $eventContextId (in thread)', + e, + s); + if (!mounted) return; + timeline = await thread!.getTimeline( + onUpdate: updateView, + onInsert: onInsert, + ); + if (!mounted) return; + if (e is TimeoutException || e is IOException) { + _showScrollUpMaterialBanner(eventContextId!); + } + } + } + + Future _getTimeline({ + String? eventContextId, + }) async { + await Matrix.of(context).client.roomsLoading; + await Matrix.of(context).client.accountDataLoading; + if (eventContextId != null && + (!eventContextId.isValidMatrixId || eventContextId.sigil != '\$')) { + eventContextId = null; + } + if (thread == null) { + await _loadRoomTimeline(eventContextId: eventContextId); + } else { + await _loadThreadTimeline(eventContextId: eventContextId); + } timeline!.requestKeys(onlineKeyBackupOnly: false); if (room.markedUnread) room.markUnread(false); @@ -471,9 +522,13 @@ class ChatController extends State .then((_) { _setReadMarkerFuture = null; }); - if (eventId == null || eventId == timeline.room.lastEvent?.eventId) { - Matrix.of(context).backgroundPush?.cancelNotification(roomId); + + if (timeline is RoomTimeline) { + if (eventId == null || eventId == timeline.room.lastEvent?.eventId) { + Matrix.of(context).backgroundPush?.cancelNotification(roomId); + } } + // TODO same for Threads } @override @@ -540,12 +595,13 @@ class ChatController extends State } // ignore: unawaited_futures - room.sendTextEvent( - sendController.text, - inReplyTo: replyEvent, - editEventId: editEvent?.eventId, - parseCommands: parseCommands, - ); + room.sendTextEvent(sendController.text, + inReplyTo: replyEvent, + editEventId: editEvent?.eventId, + parseCommands: parseCommands, + threadRootEventId: thread?.rootEvent.eventId, + threadLastEventId: + thread?.lastEvent?.eventId ?? thread?.rootEvent.eventId); sendController.value = TextEditingValue( text: pendingText, selection: const TextSelection.collapsed(offset: 0), @@ -562,8 +618,13 @@ class ChatController extends State void sendPollAction() async { await showAdaptiveDialog( - context: context, - builder: (c) => SendPollDialog(room: room, outerContext: context)); + context: context, + builder: (c) => SendPollDialog( + room: room, + thread: thread, + outerContext: context, + ), + ); replyEvent = null; } @@ -582,6 +643,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: files, room: room, + thread: thread, outerContext: context, replyEvent: replyEvent, ), @@ -596,6 +658,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: [XFile.fromData(image)], room: room, + thread: thread, outerContext: context, ), ); @@ -612,6 +675,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: [file], room: room, + thread: thread, outerContext: context, ), ); @@ -631,6 +695,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: [file], room: room, + thread: thread, outerContext: context, ), ); diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 1db76b2..178dc72 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -37,7 +37,7 @@ class ChatEventList extends StatelessWidget { final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0; - final events = timeline.events.filterByVisibleInGui().filterByThreaded(false); + final events = timeline.events.filterByVisibleInGui().filterByThreaded(controller.thread != null); final animateInEventIndex = controller.animateInEventIndex; // create a map of eventId --> index to greatly improve performance of diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index cb55680..9ba3ef3 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -3,10 +3,12 @@ import 'dart:ui' as ui; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:extera_next/utils/adaptive_bottom_sheet.dart'; import 'package:extera_next/utils/poll_events.dart'; +import 'package:extera_next/widgets/mxc_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:extera_next/generated/l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; import 'package:swipe_to_action/swipe_to_action.dart'; @@ -690,11 +692,58 @@ class Message extends StatelessWidget { : const SizedBox.shrink(), ), ), - Text( - thread == null - ? 'No thread' - : 'Has thread, last event: ${thread!.lastEvent != null ? thread!.lastEvent!.eventId : 'None'}', - ), + thread != null + ? Align( + alignment: ownMessage + ? Alignment.bottomRight + : Alignment.bottomLeft, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: InkWell( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.chat_bubble_outline, + color: Colors.grey[200], + size: 20, + ), + const SizedBox(width: 16), + thread!.lastEvent != null + ? FutureBuilder( + future: thread!.lastEvent! + .fetchSenderUser(), + builder: + (context, snapshot) { + final user = snapshot + .data ?? + event + .senderFromMemoryOrFallback; + + return Avatar( + mxContent: + user.avatarUrl, + name: user + .calcDisplayname(), + size: 24, + ); + }, + ) + : const SizedBox.shrink(), + const SizedBox(width: 6), + thread!.lastEvent != null + ? Text( + thread!.lastEvent!.text) + : const Text('Thread'), + ], + ), + onTap: () => context.go( + '/rooms/${event.roomId}/${event.eventId}', + ), + ), + ), + ) + : const SizedBox.shrink(), ], ), ), diff --git a/lib/pages/chat/send_file_dialog.dart b/lib/pages/chat/send_file_dialog.dart index 853ca2a..e00f3b2 100644 --- a/lib/pages/chat/send_file_dialog.dart +++ b/lib/pages/chat/send_file_dialog.dart @@ -23,12 +23,14 @@ import 'package:html_unescape/html_unescape.dart'; class SendFileDialog extends StatefulWidget { final Room room; + final Thread? thread; final List files; final BuildContext outerContext; final Event? replyEvent; const SendFileDialog({ required this.room, + required this.thread, required this.files, required this.outerContext, this.replyEvent, @@ -151,6 +153,8 @@ class SendFileDialogState extends State { thumbnail: thumbnail, shrinkImageMaxDimension: compress ? 1600 : null, extraContent: extraContent, + threadLastEventId: widget.thread?.lastEvent?.eventId ?? widget.thread?.rootEvent.eventId, + threadRootEventId: widget.thread?.rootEvent.eventId ); } on MatrixException catch (e) { final retryAfterMs = e.retryAfterMs; diff --git a/lib/pages/chat/send_poll_dialog.dart b/lib/pages/chat/send_poll_dialog.dart index 6f81ffd..3142829 100644 --- a/lib/pages/chat/send_poll_dialog.dart +++ b/lib/pages/chat/send_poll_dialog.dart @@ -1,5 +1,4 @@ import 'package:uuid/uuid.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:extera_next/generated/l10n/l10n.dart'; import 'package:matrix/matrix.dart'; @@ -8,9 +7,11 @@ class SendPollDialog extends StatefulWidget { final Room room; final BuildContext outerContext; final Event? replyEvent; + final Thread? thread; const SendPollDialog({ required this.room, + required this.thread, required this.outerContext, this.replyEvent, super.key, @@ -74,11 +75,13 @@ class SendPollDialogState extends State { 'm.text': question, }, 'answers': answers - .map((answer) => { - 'id': const Uuid().v4(), - 'org.matrix.msc1767.text': answer, - 'm.text': answer, - }) + .map( + (answer) => { + 'id': const Uuid().v4(), + 'org.matrix.msc1767.text': answer, + 'm.text': answer, + }, + ) .toList(), 'max_selections': _maxSelections, 'kind': _kind, @@ -86,7 +89,13 @@ class SendPollDialogState extends State { }; try { - await widget.room.sendEvent(pollContent, type: 'org.matrix.msc3381.poll.start'); + await widget.room.sendEvent( + pollContent, + type: 'org.matrix.msc3381.poll.start', + threadLastEventId: widget.thread?.lastEvent?.eventId ?? + widget.thread?.rootEvent.eventId, + threadRootEventId: widget.thread?.rootEvent.eventId, + ); // ignore: use_build_context_synchronously Navigator.of(context).pop(); } catch (e) { @@ -154,7 +163,7 @@ class SendPollDialogState extends State { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _maxSelections, + initialValue: _maxSelections, decoration: InputDecoration( labelText: L10n.of(context).maxSelections, border: const OutlineInputBorder(), @@ -170,7 +179,7 @@ class SendPollDialogState extends State { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _kind, + initialValue: _kind, decoration: InputDecoration( labelText: L10n.of(context).pollType, border: const OutlineInputBorder(), @@ -202,4 +211,4 @@ class SendPollDialogState extends State { ], ); } -} \ No newline at end of file +} diff --git a/lib/pages/thread/thread.dart b/lib/pages/thread/thread.dart new file mode 100644 index 0000000..b1f0bf4 --- /dev/null +++ b/lib/pages/thread/thread.dart @@ -0,0 +1,47 @@ +import 'package:extera_next/generated/l10n/l10n.dart'; +import 'package:extera_next/pages/chat/chat.dart'; +import 'package:extera_next/widgets/matrix.dart'; +import 'package:extera_next/widgets/share_scaffold_dialog.dart'; +import 'package:flutter/material.dart'; + +class ThreadPage extends StatelessWidget { + final String roomId; + final List? shareItems; + final String? threadRootEventId; + final String? eventId; + + const ThreadPage({ + super.key, + required this.roomId, + required this.threadRootEventId, + this.eventId, + this.shareItems, + }); + + @override + Widget build(BuildContext context) { + final client = Matrix.of(context).client; + final room = client.getRoomById(roomId); + if (room == null) { + return Scaffold( + appBar: AppBar(title: Text(L10n.of(context).oopsSomethingWentWrong)), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat), + ), + ), + ); + } + + final thread = room.threads[threadRootEventId]; + + return ChatPageWithRoom( + key: Key('chat_page_${roomId}_${threadRootEventId}_$eventId'), + room: room, + thread: thread, + shareItems: shareItems, + eventId: eventId, + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 06fcbca..431f0cc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2171,7 +2171,7 @@ packages: source: hosted version: "3.1.4" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff diff --git a/pubspec.yaml b/pubspec.yaml index 5a88b0c..2f81498 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -102,6 +102,7 @@ dependencies: wakelock_plus: ^1.2.2 webrtc_interface: ^1.0.13 dio: ^5.9.0 + uuid: ^4.5.1 dev_dependencies: flutter_lints: ^3.0.0