diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 955056dbd..945f4c3c7 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": { @@ -3441,6 +3441,18 @@ } }, "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", "ignore": "Block", "ignoredUsers": "Blocked users", "writeAMessageLangCodes": "Type in {l1} or {l2}...", diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 2b51183bb..4a70778f5 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -202,6 +202,8 @@ class ChatController extends State Timeline? timeline; + String? activeThreadId; + late final String readMarkerEventId; String get roomId => widget.room.id; @@ -231,6 +233,8 @@ class ChatController extends State // files: details.files, // room: room, // outerContext: context, + // threadRootEventId: activeThreadId, + // threadLastEventId: threadLastEventId, // ), // ); // } @@ -274,6 +278,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; @@ -387,6 +410,8 @@ class ChatController extends State files: files, room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -563,7 +588,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++) { @@ -590,6 +617,7 @@ class ChatController extends State : timeline!.events .filterByVisibleInGui( exceptionEventId: readMarkerEventId, + threadId: activeThreadId, ) .indexWhere((e) => e.eventId == readMarkerEventId); @@ -600,6 +628,7 @@ class ChatController extends State readMarkerEventIndex = timeline!.events .filterByVisibleInGui( exceptionEventId: readMarkerEventId, + threadId: activeThreadId, ) .indexWhere((e) => e.eventId == readMarkerEventId); } @@ -971,8 +1000,13 @@ class ChatController extends State // room.sendTextEvent( // sendController.text, // inReplyTo: replyEvent, - // editEventId: editEvent?.eventId, + // editEventId: editEvent.eventId, // parseCommands: parseCommands, + // threadRootEventId: activeThreadId, + // ); + // sendController.value = TextEditingValue( + // text: pendingText, + // selection: const TextSelection.collapsed(offset: 0), // ); // If the message and the sendController text don't match, it's possible // that there was a delay in tokenization before send, and the user started @@ -998,6 +1032,7 @@ class ChatController extends State tokensWritten: content.tokensWritten, choreo: content.choreo, txid: tempEventId, + threadRootEventId: activeThreadId, ) .then( (String? msgEventId) async { @@ -1092,6 +1127,8 @@ class ChatController extends State files: files, room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -1104,6 +1141,8 @@ class ChatController extends State files: [XFile.fromData(image)], room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -1120,6 +1159,8 @@ class ChatController extends State files: [file], room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -1139,6 +1180,8 @@ class ChatController extends State files: [file], room: room, outerContext: context, + threadRootEventId: activeThreadId, + threadLastEventId: threadLastEventId, ), ); } @@ -1192,6 +1235,7 @@ class ChatController extends State // inReplyTo: replyEvent, inReplyTo: replyEvent.value, // Pangea# + threadRootEventId: activeThreadId, extraContent: { 'info': { ...file.info, @@ -1570,6 +1614,7 @@ class ChatController extends State : timeline!.events .filterByVisibleInGui( exceptionEventId: eventId, + threadId: activeThreadId, ) .indexOf(foundEvent); diff --git a/lib/pages/chat/chat_emoji_picker.dart b/lib/pages/chat/chat_emoji_picker.dart index 48e0f15e3..7428f8980 100644 --- a/lib/pages/chat/chat_emoji_picker.dart +++ b/lib/pages/chat/chat_emoji_picker.dart @@ -103,6 +103,8 @@ class ChatEmojiPicker extends StatelessWidget { // 'url': sticker.url.toString(), // }, // type: EventTypes.Sticker, + // threadRootEventId: controller.activeThreadId, + // threadLastEventId: controller.threadLastEventId, // ); // controller.hideEmojiPicker(); // }, diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index aa70c6d0c..94a04cbf0 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -37,7 +37,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 @@ -111,7 +113,8 @@ class ChatEventList extends StatelessWidget { child: CircularProgressIndicator.adaptive(strokeWidth: 2), ); } - if (timeline.canRequestHistory) { + if (timeline.canRequestHistory && + controller.activeThreadId == null) { return Builder( builder: (context) { // #Pangea @@ -132,7 +135,6 @@ class ChatEventList extends StatelessWidget { } return const SizedBox.shrink(); } - // #Pangea if (i == 1) { return ActivityUserSummaries(controller: controller); @@ -207,6 +209,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 5ea673739..26c92685e 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:badges/badges.dart'; +import 'package:desktop_drop/desktop_drop.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; @@ -54,11 +55,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), @@ -86,6 +91,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), @@ -208,13 +225,17 @@ class ChatView extends StatelessWidget { final accountConfig = Matrix.of(context).client.applicationAccountConfig; return PopScope( - canPop: controller.selectedEvents.isEmpty && !controller.showEmojiPicker, + canPop: controller.selectedEvents.isEmpty && + !controller.showEmojiPicker && + controller.activeThreadId == null, onPopInvokedWithResult: (pop, _) async { if (pop) return; if (controller.selectedEvents.isNotEmpty) { controller.clearSelectedEvents(); } else if (controller.showEmojiPicker) { controller.emojiPickerAction(); + } else if (controller.activeThreadId != null) { + controller.closeThread(); } }, child: StreamBuilder( @@ -235,6 +256,10 @@ class ChatView extends StatelessWidget { } // Pangea# var appbarBottomHeight = 0.0; + final activeThreadId = controller.activeThreadId; + if (activeThreadId != null) { + appbarBottomHeight += ChatAppBarListTile.fixedHeight; + } if (controller.room.pinnedEventIds.isNotEmpty) { appbarBottomHeight += ChatAppBarListTile.fixedHeight; } @@ -250,7 +275,9 @@ class ChatView extends StatelessWidget { // : theme.colorScheme.onTertiaryContainer, // ), // backgroundColor: controller.selectedEvents.isEmpty - // ? null + // ? controller.activeThreadId != null + // ? theme.colorScheme.secondaryContainer + // : null // : theme.colorScheme.tertiaryContainer, // Pangea# automaticallyImplyLeading: false, @@ -261,39 +288,49 @@ class ChatView extends StatelessWidget { tooltip: L10n.of(context).close, color: theme.colorScheme.onTertiaryContainer, ) - // #Pangea - : controller.widget.backButton != null - ? controller.widget.backButton! - // : FluffyThemes.isColumnMode(context) - // ? null - // Pangea# - : StreamBuilder( - stream: - Matrix.of(context).client.onSync.stream.where( + : activeThreadId != null + ? IconButton( + icon: const Icon(Icons.close), + onPressed: controller.closeThread, + tooltip: L10n.of(context).backToMainChat, + color: theme.colorScheme.onSecondaryContainer, + ) + // #Pangea + : controller.widget.backButton != null + ? controller.widget.backButton! + // : FluffyThemes.isColumnMode(context) + // ? null + // Pangea# + : StreamBuilder( + stream: Matrix.of(context) + .client + .onSync + .stream + .where( (syncUpdate) => syncUpdate.hasRoomUpdate, ), - // #Pangea - // builder: (context, _) => UnreadRoomsBadge( - // filter: (r) => r.id != controller.roomId, - // badgePosition: - // BadgePosition.topEnd(end: 8, top: 4), - // child: const Center(child: BackButton()), - // ), - builder: (context, _) => Center( - child: SizedBox( - height: kToolbarHeight, - child: UnreadRoomsBadge( - filter: (r) => r.id != controller.roomId, - badgePosition: BadgePosition.topEnd( - end: 8, - top: 9, + // #Pangea + // builder: (context, _) => UnreadRoomsBadge( + // filter: (r) => r.id != controller.roomId, + // badgePosition: + // BadgePosition.topEnd(end: 8, top: 4), + // child: const Center(child: BackButton()), + // ), + builder: (context, _) => Center( + child: SizedBox( + height: kToolbarHeight, + child: UnreadRoomsBadge( + filter: (r) => r.id != controller.roomId, + badgePosition: BadgePosition.topEnd( + end: 8, + top: 9, + ), + child: const Center(child: BackButton()), + ), ), - child: const Center(child: BackButton()), ), + // Pangea# ), - ), - // Pangea# - ), titleSpacing: FluffyThemes.isColumnMode(context) ? 24 : 0, title: ChatAppBarTitle(controller), actions: _appBarActions(context), @@ -302,6 +339,18 @@ class ChatView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (activeThreadId != null) + SizedBox( + height: ChatAppBarListTile.fixedHeight, + child: Center( + child: TextButton.icon( + onPressed: () => + controller.scrollToEventId(activeThreadId), + icon: const Icon(Icons.message), + label: Text(L10n.of(context).replyInThread), + ), + ), + ), PinnedEvents(controller), if (scrollUpBannerEventId != null) ChatAppBarListTile( @@ -548,7 +597,7 @@ class ChatView extends StatelessWidget { // ], // ), // ), - // ) + // ), else if (controller.room.canSendDefaultMessages && controller.room.membership == Membership.join && (controller.room.activityPlan == null || diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 2a52f7e71..330f77fe5 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -22,6 +22,7 @@ import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.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'; @@ -42,6 +43,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; @@ -82,6 +84,7 @@ class Message extends StatelessWidget { required this.scrollController, required this.colors, this.onExpand, + required this.enterThread, this.isCollapsed = false, // #Pangea required this.controller, @@ -296,11 +299,16 @@ class Message extends StatelessWidget { final showReceiptsRow = event.hasAggregatedEvents(timeline, RelationshipTypes.reaction); + final threadChildren = + event.aggregatedEvents(timeline, RelationshipTypes.thread); + // #Pangea // final showReactionPicker = // singleSelected && event.room.canSendDefaultMessages; // Pangea# + final enterThread = this.enterThread; + return Center( child: Swipeable( key: ValueKey(event.eventId), @@ -605,6 +613,29 @@ class Message extends StatelessWidget { child: ValueListenableBuilder( valueListenable: controller .depressMessageButton, + builder: ( + context, + depressed, + child, + ) => + PressableButton( + buttonHeight: 5, + depressed: !isButton || + depressed, + borderRadius: + borderRadius, + onPressed: () { + showToolbar( + pangeaMessageEvent, + ); + }, + color: color, + visible: + isButton && !noBubble, + builder: + (context, _, __) => + child!, + ), // Pangea# child: Container( @@ -641,14 +672,6 @@ class Message extends StatelessWidget { scrollController: scrollController, child: Container( - // #Pangea - key: MatrixState - .pAnyState - .layerLinkAndKey( - event.eventId, - ) - .key, - // Pangea# decoration: BoxDecoration( borderRadius: @@ -672,15 +695,10 @@ class Message extends StatelessWidget { CrossAxisAlignment .start, children: [ - if ({ - RelationshipTypes - .reply, - RelationshipTypes - .thread, - }.contains( - event - .relationshipType, - )) + if (RelationshipTypes + .reply == + event + .relationshipType) FutureBuilder< Event?>( future: event @@ -702,11 +720,7 @@ class Message extends StatelessWidget { 'msgtype': 'm.text', 'body': '...', }, - // #Pangea - // senderId: event - // .senderId, - senderId: "", - // Pangea# + senderId: event.senderId, type: 'm.room.message', room: event.room, status: EventStatus.sent, @@ -833,31 +847,6 @@ class Message extends StatelessWidget { ), ), ), - // #Pangea - builder: ( - context, - depressed, - child, - ) => - PressableButton( - buttonHeight: 5, - depressed: !isButton || - depressed, - borderRadius: - borderRadius, - onPressed: () { - showToolbar( - pangeaMessageEvent, - ); - }, - color: color, - visible: - isButton && !noBubble, - builder: - (context, _, __) => - child!, - ), - // Pangea# ), ), ), @@ -1094,6 +1083,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: 1.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.first.calcLocalizedBodyFallback( + MatrixLocals(L10n.of(context)), + withSenderNamePrefix: true, + )}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ), !showReceiptsRow ? const SizedBox.shrink() : Padding( 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 f10290257..cb0a3f99b 100644 --- a/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart +++ b/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart @@ -9,14 +9,26 @@ import 'package:fluffychat/utils/matrix_sdk_extensions/filtered_timeline_extensi extension VisibleInGuiExtension on List { List filterByVisibleInGui({ String? exceptionEventId, + String? threadId, }) => where( - // #Pangea - // (event) => event.isVisibleInGui || event.eventId == exceptionEventId, - (event) => - (event.isVisibleInGui || event.eventId == exceptionEventId) && - event.isVisibleInPangeaGui, - // Pangea# + (event) { + if (threadId != null && + event.relationshipType != RelationshipTypes.reaction) { + if ((event.relationshipType != RelationshipTypes.thread || + event.relationshipEventId != threadId) && + event.eventId != threadId) { + return false; + } + } else if (event.relationshipType == RelationshipTypes.thread) { + return false; + } + // #Pangea + // return event.isVisibleInGui || event.eventId == exceptionEventId; + return (event.isVisibleInGui || event.eventId == exceptionEventId) && + event.isVisibleInPangeaGui; + // Pangea# + }, ).toList(); }