diff --git a/lib/controllers/contextual_event.dart b/lib/controllers/contextual_event.dart new file mode 100644 index 0000000..2bc6b3a --- /dev/null +++ b/lib/controllers/contextual_event.dart @@ -0,0 +1,17 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/event.dart"; + +class ContextualEventController(String? _) extends Notifier { + @override + Event? build() => null; + + void set(Event? event) => state = event; + + @override + bool updateShouldNotify(_, _) => true; + + static final provider = NotifierProvider.family + .autoDispose( + ContextualEventController.new, + ); +} diff --git a/lib/controllers/jump_to_event.dart b/lib/controllers/jump_to_event.dart deleted file mode 100644 index da42cf9..0000000 --- a/lib/controllers/jump_to_event.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class JumpToEventController(String? _) extends Notifier { - @override - String? build() => null; - - void set(String? eventId) => state = eventId; - - @override - bool updateShouldNotify(_, _) => true; - - static final provider = NotifierProvider.family - .autoDispose( - JumpToEventController.new, - ); -} diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 91b137a..bb24fcf 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -18,8 +18,9 @@ import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/room_chat.dart"; -class RoomChatController(final (String roomId, String? contextualEvent) info) - extends AsyncNotifier { +class RoomChatController( + final (String roomId, (int rowId, String eventId)? contextualEvent) info, +) extends AsyncNotifier { @override Future build() async { final (roomId, eventId) = info; @@ -37,29 +38,15 @@ class RoomChatController(final (String roomId, String? contextualEvent) info) final timeline = room.timeline .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) - .map((element) => element.value) + .map((e) => e.value) .toIList() .addAll(room.clientSticky) - .map((entry) { - final foundEvent = entry == null ? null : room.events[entry]; - - final editedEvent = - foundEvent == null || foundEvent.lastEditRowId == 0 - ? null - : room.events[foundEvent.lastEditRowId]; - - return editedEvent == null - ? foundEvent - : foundEvent?.copyWith( - content: editedEvent.content, - localContent: editedEvent.localContent, - ); - }) .nonNulls .toIList(); - - if (info.$2 == null || timeline.map((e) => e.eventId).contains(info.$2)) { - ref.watch(RoomsController.provider.select((rooms) => rooms[roomId])); + if (info.$2 == null || timeline.contains(info.$2!.$1)) { + ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]?.timeline), + ); return .new( timeline: timeline, @@ -68,10 +55,25 @@ class RoomChatController(final (String roomId, String? contextualEvent) info) ); } else { final context = await client.getEventContext( - .new(roomId: roomId, eventId: info.$2!), + .new(roomId: roomId, eventId: info.$2!.$2), ); + + final events = context.before.add(context.event).addAll(context.after); + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + roomId: Room( + events: IMap.fromIterable( + events, + keyMapper: (event) => event.rowId, + ), + ), + }), + .new(), + ); return .new( - timeline: context.before.add(context.event).addAll(context.after), + timeline: .new(events.map((element) => element.rowId)), hasMoreBackward: true, hasMoreForward: true, historicalData: .new(start: context.start, end: context.end), @@ -153,11 +155,29 @@ class RoomChatController(final (String roomId, String? contextualEvent) info) ), ); + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.$1: Room( + events: IMap.fromIterable( + paginationResponse.events, + keyMapper: (event) => event.rowId, + ), + ), + }), + .new(), + ); + + final eventRowIds = paginationResponse.events + .map((element) => element.rowId) + .toIList(); + state = .data( .new( timeline: direction == .forward - ? chat.timeline.addAll(paginationResponse.events) - : paginationResponse.events.addAll(chat.timeline), + ? chat.timeline.addAll(eventRowIds) + : eventRowIds.addAll(chat.timeline), hasMoreForward: direction == .forward && paginationResponse.nextBatch == null ? false @@ -293,7 +313,9 @@ class RoomChatController(final (String roomId, String? contextualEvent) info) } static final provider = AsyncNotifierProvider.family - .autoDispose( - RoomChatController.new, - ); + .autoDispose< + RoomChatController, + RoomChat?, + (String, (int rowId, String eventId)?) + >(RoomChatController.new); } diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index 8c26433..d3ef9e0 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -9,33 +9,33 @@ import "package:nexus/models/event.dart"; import "package:nexus/models/room_chat.dart"; final class ChatScroll({ - required final IList historyItems, - required final IList liveItems, + required final IList historyRows, + required final IList liveRows, required final GlobalKey centerKey, required final ScrollController scrollController, required final bool atBottom, - required final Future Function(String id) jumpToId, + required final Future Function(Event event) jumpToEvent, required final Future Function() jumpToBottom, - required final GlobalKey Function(String eventId) keyFor, + required final GlobalKey Function(int eventRowId) keyFor, }) { factory use({ required AsyncValue controllerData, required Future Function(Direction direction) paginate, required Future Function() markRead, - required ValueNotifier contextualEvent, + required ValueNotifier contextualEvent, }) { - final anchorId = useState(null); + final anchorId = useState(null); - final itemKeys = useMemoized(() => {}, []); - GlobalKey keyFor(String eventId) => - itemKeys.putIfAbsent(eventId, GlobalKey.new); + final itemKeys = useMemoized(() => {}, []); + GlobalKey keyFor(int eventRowId) => + itemKeys.putIfAbsent(eventRowId, GlobalKey.new); final scrollController = useScrollController(); final centerKey = useMemoized(GlobalKey.new); final atBottom = useState(true); - final pendingAnchorTarget = useState(null); + final pendingAnchorTarget = useState(null); final anchorMountedCompleter = useRef?>(null); useEffect(() { @@ -43,12 +43,12 @@ final class ChatScroll({ if (controllerData case AsyncData(:final value?) when value.timeline.isNotEmpty) { final hasContextualEvent = value.timeline.any( - (event) => event.eventId == contextualEvent.value, + (event) => event == contextualEvent.value?.rowId, ); anchorId.value = hasContextualEvent - ? contextualEvent.value - : value.timeline.last.eventId; + ? contextualEvent.value?.rowId + : value.timeline.last; } } @@ -60,9 +60,7 @@ final class ChatScroll({ if (target == null) return null; final found = - controllerData.value?.timeline.any( - (event) => event.eventId == target, - ) ?? + controllerData.value?.timeline.any((event) => event == target) ?? false; if (found || controllerData is AsyncError) { @@ -88,7 +86,7 @@ final class ChatScroll({ return null; }, [controllerData, pendingAnchorTarget.value]); - final ({IList history, IList live}) split = useMemoized(() { + final ({IList history, IList live}) split = useMemoized(() { final items = controllerData.value?.timeline; final anchor = anchorId.value; @@ -96,7 +94,7 @@ final class ChatScroll({ return (history: const .empty(), live: const .empty()); } - final anchorIndex = items.indexWhere((item) => item.eventId == anchor); + final anchorIndex = items.indexOf(anchor); if (anchorIndex == -1) { return (history: const .empty(), live: items); @@ -155,15 +153,15 @@ final class ChatScroll({ ); return .new( - historyItems: split.history, - liveItems: split.live, + historyRows: split.history, + liveRows: split.live, centerKey: centerKey, scrollController: scrollController, atBottom: atBottom.value, - jumpToId: (String itemId) async { + jumpToEvent: (Event event) async { if (!scrollController.hasClients) return; - final existing = keyFor(itemId).currentContext; + final existing = keyFor(event.rowId).currentContext; if (existing != null && existing.mounted) { // Already mounted, just scroll await Scrollable.ensureVisible( @@ -175,8 +173,8 @@ final class ChatScroll({ } else { final completer = Completer(); anchorMountedCompleter.value = completer; - pendingAnchorTarget.value = itemId; - contextualEvent.value = itemId; + pendingAnchorTarget.value = event.rowId; + contextualEvent.value = event; final context = await completer.future; if (!context.mounted) return; diff --git a/lib/models/room_chat.dart b/lib/models/room_chat.dart index a038a88..868a99e 100644 --- a/lib/models/room_chat.dart +++ b/lib/models/room_chat.dart @@ -8,7 +8,8 @@ part "room_chat.g.dart"; @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const RoomChat({ - required final IList timeline, + /// List of eventRowId + required final IList timeline, required final bool hasMoreForward, required final bool hasMoreBackward, final HistoricalData? historicalData, diff --git a/lib/widgets/event_preview.dart b/lib/widgets/event_preview.dart index 30dfbfe..9bd3392 100644 --- a/lib/widgets/event_preview.dart +++ b/lib/widgets/event_preview.dart @@ -24,7 +24,12 @@ class const EventPreview(final Event event, {super.key}) runSpacing: 2, children: [ if (event.content is MessageContent) MessageDisplayname(event), - EventRenderer(event, textOnly: true, maxLines: 1), + EventRenderer( + event.rowId, + roomId: event.roomId, + textOnly: true, + maxLines: 1, + ), ], ), ), diff --git a/lib/widgets/pages/chat.dart b/lib/widgets/pages/chat.dart index d3886d3..52e5cc8 100644 --- a/lib/widgets/pages/chat.dart +++ b/lib/widgets/pages/chat.dart @@ -1,7 +1,7 @@ import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/init_complete.dart"; -import "package:nexus/controllers/jump_to_event.dart"; +import "package:nexus/controllers/contextual_event.dart"; import "package:nexus/controllers/key.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/sidebar.dart"; @@ -30,7 +30,7 @@ class const ChatPage({super.key}) extends HookConsumerWidget { child: Consumer( builder: (context, ref, _) { final initialHighlight = ref.watch( - JumpToEventController.provider(roomId), + ContextualEventController.provider(roomId), ); return RoomChat( key: ValueKey((roomId, initialHighlight)), diff --git a/lib/widgets/pages/notifications.dart b/lib/widgets/pages/notifications.dart index 2ad8930..d1886d0 100644 --- a/lib/widgets/pages/notifications.dart +++ b/lib/widgets/pages/notifications.dart @@ -5,7 +5,7 @@ import "package:m3e_buttons/m3e_buttons.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/jump_to_event.dart"; +import "package:nexus/controllers/contextual_event.dart"; import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/notifications.dart"; import "package:nexus/controllers/spaces.dart"; @@ -168,18 +168,21 @@ class const NotificationsPage({ .set(event.roomId); ref .watch( - JumpToEventController.provider( + ContextualEventController.provider( event.roomId, ).notifier, ) - .set(event.eventId); + .set(event); if (context.mounted) { Navigator.of(context).pop(); } }, child: IgnorePointer( - child: EventRenderer(event), + child: EventRenderer( + event.rowId, + roomId: event.roomId, + ), ), ), isHighlighted: isHighlighted, diff --git a/lib/widgets/pinned_events_drawer.dart b/lib/widgets/pinned_events_drawer.dart index 73d9a79..cb91985 100644 --- a/lib/widgets/pinned_events_drawer.dart +++ b/lib/widgets/pinned_events_drawer.dart @@ -10,7 +10,7 @@ import "package:nexus/widgets/renderers/event.dart"; final class const PinnedEventsDrawer( final String roomId, { required final IList Function(Event event) getEventOptions, - required final Future Function(String eventId) jumpToId, + required final Future Function(Event event) jumpToEvent, super.key, }) extends HookConsumerWidget { @override @@ -62,15 +62,15 @@ final class const PinnedEventsDrawer( borderRadius: .circular(12), onTap: () { Navigator.of(context).pop(); - jumpToId(event.eventId); + jumpToEvent(event); }, child: Padding( padding: .symmetric(vertical: 4), child: IgnorePointer( child: EventRenderer( - event, + event.rowId, + roomId: roomId, maxLines: 2, - isGrouped: false, getEventOptions: getEventOptions, ), ), diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index 286646f..195aa4f 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -8,6 +8,7 @@ import "package:nexus/helpers/mxc_image.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/main.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { @override @@ -21,20 +22,22 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { .new(roomId: event.roomId, eventRowId: event.rowId), ), )) { - AsyncData(value: final reactions) || - AsyncLoading(value: final reactions?) => Wrap( + AsyncData(value: final IMap>? reactors) || + AsyncLoading(value: final reactors) => Wrap( spacing: 4, runSpacing: 4, - children: reactions - .where((_, value) => value.isNotEmpty) + children: event.reactions + .where((_, value) => value != 0) .mapTo( - (reaction, reactors) => HookBuilder( + (reaction, count) => HookBuilder( builder: (context) { final enabled = useState(true); - final selected = reactors.contains(clientState!.userId); + final selected = + reactors?[reaction]?.contains(clientState!.userId) ?? + false; return Tooltip( - message: reactors.join(", "), + message: reactors?[reaction]?.join(", ") ?? "", child: ChoiceChip( showCheckmark: false, selected: selected, @@ -53,10 +56,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { ) : Text(reaction, overflow: .ellipsis), ), - Text( - reactors.length.toString(), - overflow: .ellipsis, - ), + Text(count.toString(), overflow: .ellipsis), ], ), onSelected: enabled.value @@ -75,7 +75,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { .removeReaction( reaction, event, - clientState.userId!, + clientState!.userId!, ) .onError(showError); } else { @@ -100,8 +100,6 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { error, stackTrace, ), - - _ => SizedBox.shrink(), }, ); } diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 697161f..14dcd70 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -3,6 +3,8 @@ import "package:flutter/gestures.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; @@ -28,11 +30,12 @@ import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/timestamp.dart"; class const EventRenderer( - final Event event, { + final int eventRowId, { + required final String roomId, + final int? previousEventRowId, final bool textOnly = false, - final bool isGrouped = false, final int? maxLines, - final VoidCallback? onTapReply, + final Future Function(Event event)? jumpToEvent, final IList Function(Event event)? getEventOptions, super.key, }) extends HookConsumerWidget { @@ -44,6 +47,26 @@ class const EventRenderer( final focusNode = useFocusNode(); useListenable(focusNode); + final (event, previousEvent) = ref.watch( + RoomsController.provider.select( + (value) => ( + value[roomId]?.events[eventRowId], + previousEventRowId == null + ? null + : value[roomId]?.events[previousEventRowId!], + ), + ), + ); + + if (event == null) return SizedBox.shrink(); + + final isGrouped = + previousEvent?.content is MessageContent && + previousEvent?.redactedBy == null && + previousEvent?.relationType != "m.replace" && + event.sender == previousEvent?.sender && + event.pmp?.id == previousEvent?.pmp?.id; + final child = event.redactedBy != null || event.relationType == "m.replace" ? null : switch (event.content) { @@ -60,7 +83,16 @@ class const EventRenderer( EncryptedContent() || StickerContent() => MessageRenderer( event, - onTapReply: onTapReply, + onTapReply: jumpToEvent == null + ? null + : () async { + final replyEvent = await ref.read( + EventController.provider( + .new(roomId: roomId, eventId: event.replyTo!), + ).future, + ); + if (replyEvent != null) await jumpToEvent!(replyEvent); + }, isGrouped: isGrouped, maxLines: maxLines, textOnly: textOnly, diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 2cab4ca..6ae61eb 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -1,43 +1,34 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; -import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/highlight_wrapper.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class const ChatTimeline({ + required final String roomId, required final ChatScroll scroll, - required final Future Function(String) jumpToId, + required final Future Function(Event event) jumpToEvent, required final IList Function(Event) getEventOptions, - required final String? highlightedEvent, + required final int? highlightedEvent, required final double composerHeight, super.key, }) extends StatelessWidget { - bool isGrouped(Event event, Event? previousEvent) => - previousEvent?.content is MessageContent && - previousEvent?.redactedBy == null && - previousEvent?.relationType != "m.replace" && - event.sender == previousEvent?.sender && - event.pmp?.id == previousEvent?.pmp?.id; - Widget eventRow( - Event event, - Event? previousEvent, { - required Future Function(String) jumpToId, - required IList Function(Event) getEventOptions, - required String? highlightedEvent, + int eventRowId, + int? previousEventRowId, { required Key key, }) => HighlightWrapper( EventRenderer( - event, - onTapReply: () => jumpToId(event.replyTo!), + eventRowId, + previousEventRowId: previousEventRowId, + roomId: roomId, + jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, - isGrouped: isGrouped(event, previousEvent), ), key: key, - isHighlighted: highlightedEvent == event.eventId, + isHighlighted: highlightedEvent == eventRowId, ); @override @@ -50,29 +41,23 @@ class const ChatTimeline({ SliverToBoxAdapter(child: SizedBox(height: composerHeight)), SuperSliverList.builder( - itemCount: scroll.liveItems.length, + itemCount: scroll.liveRows.length, itemBuilder: (_, index) => eventRow( - scroll.liveItems[index], + scroll.liveRows[index], index > 0 - ? scroll.liveItems.getOrNull(index - 1) - : scroll.historyItems.firstOrNull, - jumpToId: jumpToId, - getEventOptions: getEventOptions, - highlightedEvent: highlightedEvent, - key: scroll.keyFor(scroll.liveItems[index].eventId), + ? scroll.liveRows.getOrNull(index - 1) + : scroll.historyRows.firstOrNull, + key: scroll.keyFor(scroll.liveRows[index]), ), ), SuperSliverList.builder( key: scroll.centerKey, - itemCount: scroll.historyItems.length, + itemCount: scroll.historyRows.length, itemBuilder: (_, index) => eventRow( - scroll.historyItems[index], - scroll.historyItems.getOrNull(index + 1), - jumpToId: jumpToId, - getEventOptions: getEventOptions, - highlightedEvent: highlightedEvent, - key: scroll.keyFor(scroll.historyItems[index].eventId), + scroll.historyRows[index], + scroll.historyRows.getOrNull(index + 1), + key: scroll.keyFor(scroll.historyRows[index]), ), ), ], diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 709d22d..d4a26cd 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -24,15 +24,15 @@ final class const RoomChat({ required final String? roomId, required final bool isDesktop, required final bool showMembersByDefault, - final String? initialHighlightedEvent, + final Event? initialHighlightedEvent, super.key, }) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final relatedEvent = useState(null); final relationType = useState(RelationType.reply); - final contextualEvent = useState(initialHighlightedEvent); - final highlightedEvent = useState(null); + final contextualEvent = useState(initialHighlightedEvent); + final highlightedEvent = useState(null); final composerSize = useState(64); @@ -63,7 +63,9 @@ final class const RoomChat({ final controllerProvider = RoomChatController.provider(( roomId, - contextualEvent.value, + contextualEvent.value == null + ? null + : (contextualEvent.value!.rowId, contextualEvent.value!.eventId), )); final notifier = ref.watch(controllerProvider.notifier); @@ -97,12 +99,14 @@ final class const RoomChat({ }, ); - Future jumpToId(String eventId) async { - highlightedEvent.value = eventId; + Future jumpToEvent(Event event) async { + highlightedEvent.value = event.rowId; - await scroll.jumpToId(eventId); + await scroll.jumpToEvent(event); await Future.delayed(.new(milliseconds: 700), () { - if (highlightedEvent.value == eventId) highlightedEvent.value = null; + if (highlightedEvent.value == event.rowId) { + highlightedEvent.value = null; + } }); } @@ -113,7 +117,7 @@ final class const RoomChat({ if (!context.mounted) return; if (scroll.scrollController.hasClients) { - jumpToId(initialHighlightedEvent!); + jumpToEvent(initialHighlightedEvent!); } else { WidgetsBinding.instance.addPostFrameCallback((_) => check()); } @@ -141,7 +145,7 @@ final class const RoomChat({ endDrawer: PinnedEventsDrawer( roomId, getEventOptions: getEventOptions, - jumpToId: jumpToId, + jumpToEvent: jumpToEvent, ), body: Builder( builder: (middleContext) => Scaffold( @@ -168,7 +172,8 @@ final class const RoomChat({ padding: .symmetric(horizontal: 4), child: ChatTimeline( scroll: scroll, - jumpToId: jumpToId, + roomId: roomId, + jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, highlightedEvent: highlightedEvent.value, composerHeight: composerSize.value,