diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 8bd3840a9..2a56f0379 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3431,7 +3431,7 @@ "addAnswerOption": "Add answer option", "allowMultipleAnswers": "Allow multiple answers", "pollHasBeenEnded": "Poll has been ended", - "countVotes": "{count} votes", + "countVotes": "{count, plural, =1{One vote} other{{count} votes}}", "@countVotes": { "type": "int", "placeholders": { @@ -3440,5 +3440,17 @@ } } }, - "answersWillBeVisibleWhenPollHasEnded": "Answers will be visible when poll has ended" + "answersWillBeVisibleWhenPollHasEnded": "Answers will be visible when poll has ended", + "replyInThread": "Reply in thread", + "countReplies": "{count, plural, =1{One reply} other{{count} replies}}", + "@countReplies": { + "type": "int", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "thread": "Thread", + "backToMainChat": "Back to main chat" } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 79fb96779..f36ad9849 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -102,6 +102,8 @@ class ChatController extends State Timeline? timeline; + String? activeThreadId; + late final String readMarkerEventId; String get roomId => widget.room.id; @@ -130,6 +132,8 @@ class ChatController extends State files: details.files, room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -167,6 +171,25 @@ class ChatController extends State bool showEmojiPicker = false; + String? get threadLastEventId { + final threadId = activeThreadId; + if (threadId == null) return null; + return timeline?.events + .filterByVisibleInGui(threadId: threadId) + .firstOrNull + ?.eventId; + } + + void enterThread(String eventId) => setState(() { + activeThreadId = eventId; + selectedEvents.clear(); + }); + + void closeThread() => setState(() { + activeThreadId = null; + selectedEvents.clear(); + }); + void recreateChat() async { final room = this.room; final userId = room.directChatMatrixID; @@ -267,6 +290,8 @@ class ChatController extends State files: files, room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -340,7 +365,9 @@ class ChatController extends State final Set expandedEventIds = {}; void expandEventsFrom(Event event, bool expand) { - final events = timeline!.events.filterByVisibleInGui(); + final events = timeline!.events.filterByVisibleInGui( + threadId: activeThreadId, + ); final start = events.indexOf(event); setState(() { for (var i = start; i < events.length; i++) { @@ -367,6 +394,7 @@ class ChatController extends State : timeline!.events .filterByVisibleInGui( exceptionEventId: readMarkerEventId, + threadId: activeThreadId, ) .indexWhere((e) => e.eventId == readMarkerEventId); @@ -377,6 +405,7 @@ class ChatController extends State readMarkerEventIndex = timeline!.events .filterByVisibleInGui( exceptionEventId: readMarkerEventId, + threadId: activeThreadId, ) .indexWhere((e) => e.eventId == readMarkerEventId); } @@ -571,6 +600,7 @@ class ChatController extends State inReplyTo: replyEvent, editEventId: editEvent?.eventId, parseCommands: parseCommands, + threadRootEventId: activeThreadId, ); sendController.value = TextEditingValue( text: pendingText, @@ -599,6 +629,8 @@ class ChatController extends State files: files, room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -611,6 +643,8 @@ class ChatController extends State files: [XFile.fromData(image)], room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -627,6 +661,8 @@ class ChatController extends State files: [file], room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -646,6 +682,8 @@ class ChatController extends State files: [file], room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -677,6 +715,7 @@ class ChatController extends State room.sendFileEvent( file, inReplyTo: replyEvent, + threadRootEventId: activeThreadId, extraContent: { 'info': { ...file.info, @@ -966,6 +1005,7 @@ class ChatController extends State : timeline!.events .filterByVisibleInGui( exceptionEventId: eventId, + threadId: activeThreadId, ) .indexOf(foundEvent); diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 7e11588a4..0059026f5 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -36,7 +36,9 @@ class ChatEventList extends StatelessWidget { final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0; - final events = timeline.events.filterByVisibleInGui(); + final events = timeline.events.filterByVisibleInGui( + threadId: controller.activeThreadId, + ); final animateInEventIndex = controller.animateInEventIndex; // create a map of eventId --> index to greatly improve performance of @@ -95,7 +97,8 @@ class ChatEventList extends StatelessWidget { child: CircularProgressIndicator.adaptive(strokeWidth: 2), ); } - if (timeline.canRequestHistory) { + if (timeline.canRequestHistory && + controller.activeThreadId == null) { return Builder( builder: (context) { WidgetsBinding.instance @@ -165,6 +168,9 @@ class ChatEventList extends StatelessWidget { scrollController: controller.scrollController, colors: colors, isCollapsed: isCollapsed, + enterThread: controller.activeThreadId == null + ? controller.enterThread + : null, onExpand: canExpand ? () => controller.expandEventsFrom( event, diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 2d08b1a2c..137eb7170 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -43,11 +43,15 @@ class ChatView extends StatelessWidget { tooltip: L10n.of(context).edit, onPressed: controller.editSelectedEventAction, ), - IconButton( - icon: const Icon(Icons.copy_outlined), - tooltip: L10n.of(context).copy, - onPressed: controller.copyEventsAction, - ), + if (controller.selectedEvents.length == 1 && + controller.activeThreadId == null && + controller.room.canSendDefaultMessages) + IconButton( + icon: const Icon(Icons.message_outlined), + tooltip: L10n.of(context).replyInThread, + onPressed: () => controller + .enterThread(controller.selectedEvents.single.eventId), + ), if (controller.canPinSelectedEvents) IconButton( icon: const Icon(Icons.push_pin_outlined), @@ -75,6 +79,18 @@ class ChatView extends StatelessWidget { } }, itemBuilder: (context) => [ + PopupMenuItem( + onTap: controller.copyEventsAction, + value: null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.copy_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).copy), + ], + ), + ), if (controller.canSaveSelectedEvent) PopupMenuItem( onTap: () => controller.saveSelectedEvent(context), @@ -157,6 +173,8 @@ class ChatView extends StatelessWidget { controller.clearSelectedEvents(); } else if (controller.showEmojiPicker) { controller.emojiPickerAction(); + } else if (controller.activeThreadId != null) { + controller.closeThread(); } }, child: StreamBuilder( @@ -167,6 +185,10 @@ class ChatView extends StatelessWidget { future: controller.loadTimelineFuture, builder: (BuildContext context, snapshot) { var appbarBottomHeight = 0.0; + final activeThreadId = controller.activeThreadId; + if (activeThreadId != null) { + appbarBottomHeight += ChatAppBarListTile.fixedHeight; + } if (controller.room.pinnedEventIds.isNotEmpty) { appbarBottomHeight += ChatAppBarListTile.fixedHeight; } @@ -181,7 +203,9 @@ class ChatView extends StatelessWidget { : theme.colorScheme.onTertiaryContainer, ), backgroundColor: controller.selectedEvents.isEmpty - ? null + ? controller.activeThreadId != null + ? theme.colorScheme.secondaryContainer + : null : theme.colorScheme.tertiaryContainer, automaticallyImplyLeading: false, leading: controller.selectMode @@ -213,6 +237,17 @@ class ChatView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (activeThreadId != null) + SizedBox( + height: ChatAppBarListTile.fixedHeight, + child: Center( + child: TextButton.icon( + onPressed: controller.closeThread, + label: Text(L10n.of(context).backToMainChat), + icon: const Icon(Icons.message), + ), + ), + ), PinnedEvents(controller), if (scrollUpBannerEventId != null) ChatAppBarListTile( diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index f2b867927..55e554015 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -14,6 +14,7 @@ import 'package:fluffychat/pages/chat/events/room_creation_state_event.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/file_description.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -35,6 +36,7 @@ class Message extends StatelessWidget { final void Function() onSwipe; final void Function() onMention; final void Function() onEdit; + final void Function(String eventId)? enterThread; final bool longPressSelect; final bool selected; final bool singleSelected; @@ -70,6 +72,7 @@ class Message extends StatelessWidget { required this.scrollController, required this.colors, this.onExpand, + required this.enterThread, this.isCollapsed = false, super.key, }); @@ -194,9 +197,14 @@ class Message extends StatelessWidget { final showReceiptsRow = event.hasAggregatedEvents(timeline, RelationshipTypes.reaction); + final threadChildren = + event.aggregatedEvents(timeline, RelationshipTypes.thread); + final showReactionPicker = singleSelected && event.room.canSendDefaultMessages; + final enterThread = this.enterThread; + return Center( child: Swipeable( key: ValueKey(event.eventId), @@ -490,15 +498,10 @@ class Message extends StatelessWidget { CrossAxisAlignment .start, children: [ - if ({ - RelationshipTypes - .reply, - RelationshipTypes - .thread, - }.contains( - event - .relationshipType, - )) + if (RelationshipTypes + .reply == + event + .relationshipType) FutureBuilder( future: event .getReplyEvent( @@ -867,6 +870,38 @@ class Message extends StatelessWidget { child: MessageReactions(event, timeline), ), ), + if (enterThread != null) + AnimatedSize( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + alignment: Alignment.bottomCenter, + child: threadChildren.isEmpty + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(top: 2.0, bottom: 4.0), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 400, + ), + child: TextButton.icon( + style: TextButton.styleFrom( + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + ), + onPressed: () => enterThread(event.eventId), + icon: const Icon(Icons.message), + label: Text( + '${L10n.of(context).countReplies(threadChildren.length)} | ${threadChildren.last.calcLocalizedBodyFallback( + MatrixLocals(L10n.of(context)), + withSenderNamePrefix: true, + )}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ), if (displayReadMarker) Row( children: [ diff --git a/lib/pages/chat/send_file_dialog.dart b/lib/pages/chat/send_file_dialog.dart index 2da649907..deab14889 100644 --- a/lib/pages/chat/send_file_dialog.dart +++ b/lib/pages/chat/send_file_dialog.dart @@ -20,11 +20,14 @@ class SendFileDialog extends StatefulWidget { final Room room; final List files; final BuildContext outerContext; + final String? threadLastEventId, threadRootEventId; const SendFileDialog({ required this.room, required this.files, required this.outerContext, + required this.threadLastEventId, + required this.threadRootEventId, super.key, }); @@ -108,6 +111,8 @@ class SendFileDialogState extends State { thumbnail: thumbnail, shrinkImageMaxDimension: compress ? 1600 : null, extraContent: label.isEmpty ? null : {'body': label}, + threadRootEventId: widget.threadRootEventId, + threadLastEventId: widget.threadLastEventId, ); } on MatrixException catch (e) { final retryAfterMs = e.retryAfterMs; diff --git a/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart b/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart index 106b28d9a..18c664b00 100644 --- a/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart +++ b/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart @@ -5,9 +5,21 @@ import 'package:fluffychat/config/setting_keys.dart'; extension VisibleInGuiExtension on List { List filterByVisibleInGui({ String? exceptionEventId, + String? threadId, }) => where( - (event) => event.isVisibleInGui || event.eventId == exceptionEventId, + (event) { + if (threadId != null) { + if ((event.relationshipType != RelationshipTypes.thread || + event.relationshipEventId != threadId) && + event.eventId != threadId) { + return false; + } + } else if (event.relationshipType == RelationshipTypes.thread) { + return false; + } + return event.isVisibleInGui || event.eventId == exceptionEventId; + }, ).toList(); }