diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 9aaa216..9a6cf67 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -8,6 +8,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:ffi/ffi.dart"; import "package:flutter/cupertino.dart"; import "package:intl/intl.dart"; +import "package:nexus/controllers/settings.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/models/capabilities.dart"; import "package:nexus/models/content/message.dart"; @@ -344,9 +345,14 @@ class ClientController extends AsyncNotifier { if (event == null) return; + // Private until settings have loaded, in case the user turned sending off + final sendPublic = + ref.read(SettingsController.provider).value?.readReceipts == + .showAndSend; + await _sendCommand("mark_read", { "room_id": room.metadata!.id, - "receipt_type": "m.read", + "receipt_type": sendPublic ? "m.read" : "m.read.private", "event_id": event.eventId, }); } diff --git a/lib/controllers/read_receipts.dart b/lib/controllers/read_receipts.dart new file mode 100644 index 0000000..15f76aa --- /dev/null +++ b/lib/controllers/read_receipts.dart @@ -0,0 +1,90 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/helpers/extensions/is_shown_as_message.dart"; +import "package:nexus/models/read_receipt.dart"; + +/// Other users' read receipts in a chat, by the event ID of the message to +/// show them under: the nearest message at or before what they've read. +class ReadReceiptsController(final ChatParams chat) + extends Notifier>> { + @override + IMap> build() { + final shouldShow = ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.readReceipts != .neither, + ), + ); + if (!shouldShow) return const .empty(); + + final userId = ref.watch( + ClientStateController.provider.select((state) => state?.userId), + ); + // The chat's own timeline, so receipts follow what's actually shown + final timeline = ref.watch( + RoomChatController.provider(chat) + .select((roomChat) => roomChat.value?.timeline), + ); + final room = ref.watch( + RoomsController.provider.select( + (rooms) => switch (rooms[chat.roomId]) { + final room? => (events: room.events, receipts: room.receipts), + null => null, + }, + ), + ); + if (userId == null || timeline == null || room == null) { + return const .empty(); + } + + final events = timeline.map((rowId) => room.events[rowId]).nonNulls; + final indexes = IMap.fromEntries( + events.indexed.map((entry) => MapEntry(entry.$2.eventId, entry.$1)), + ); + final messages = events.indexed + .where((entry) => entry.$2.isShownAsMessage) + .toIList(); + final messageIndexes = messages.map((entry) => entry.$1).toList(); + + return room.receipts.values.flattened + .where( + (receipt) => + receipt.userId != userId && + // Receipts in threads are for the thread, not the timeline + receipt.thread == "main", + ) + .map( + (receipt) => switch (indexes[receipt.eventId]) { + final index? => ( + receipt: receipt, + message: lowerBound(messageIndexes, index + 1) - 1, + ), + null => null, + }, + ) + .nonNulls + .where((entry) => entry.message >= 0) + .groupListsBy((entry) => messages[entry.message].$2.eventId) + .map( + (eventId, entries) => MapEntry( + eventId, + entries + .map((entry) => entry.receipt) + .sorted((a, b) => b.timestamp.compareTo(a.timestamp)) + .toIList(), + ), + ) + .toIMap(); + } + + static final provider = NotifierProvider.family + .autoDispose< + ReadReceiptsController, + IMap>, + ChatParams + >(ReadReceiptsController.new); +} diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 4ed6462..b8b688d 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -156,6 +156,7 @@ class RoomChatController(final ChatParams info) valueMapper: (event) => event, ), hasMore: response.hasMore, + receipts: response.receipts, timeline: IMap.fromIterable( response.events, keyMapper: (event) => event.timelineRowId, diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart index 8b740c6..f846718 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -2,6 +2,7 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room.dart"; class RoomsController extends Notifier> { @@ -33,6 +34,26 @@ class RoomsController extends Notifier> { .new(), ); + static IMap> mergeReceipts( + IMap> existing, + IMap> incoming, + ) => incoming.isEmpty + ? existing + : existing.values.flattened + .followedBy(incoming.values.flattened) + .groupListsBy((receipt) => (receipt.userId, receipt.thread)) + .values + .map( + (receipts) => receipts.reduce( + (newest, receipt) => newest.timestamp.isAfter(receipt.timestamp) + ? newest + : receipt, + ), + ) + .groupListsBy((receipt) => receipt.eventId) + .map((eventId, receipts) => MapEntry(eventId, receipts.toIList())) + .toIMap(); + /// Adds events to their rooms, without changing the rooms' timelines. void storeEvents(Iterable events) => update( .new( @@ -96,15 +117,11 @@ class RoomsController extends Notifier> { timeline: incoming.reset ? incoming.timeline : existing.timeline.addAll(incoming.timeline), - receipts: incoming.receipts.entries.fold( - existing.receipts, - (receiptAcc, event) => receiptAcc.add( - event.key, - (receiptAcc[event.key] ?? .new()).addAll(event.value), - ), - ), + receipts: mergeReceipts(existing.receipts, incoming.receipts), ) ?? - incoming, + incoming.copyWith( + receipts: mergeReceipts(const .empty(), incoming.receipts), + ), ); }); diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 596fc95..9a437d8 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -19,8 +19,9 @@ import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/helpers/extensions/distributor_name.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/room_order.dart"; +import "package:nexus/models/settings.dart"; import "package:nexus/models/settings_category.dart"; -import "package:nexus/models/typing_indicators.dart"; +import "package:nexus/models/show_and_send.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart"; @@ -32,6 +33,30 @@ class SettingsSectionsController Future>> build() async { final settings = await ref.watch(SettingsController.provider.future); + /// A tile for a [ShowAndSend] setting, saving the settings from [update]. + Widget showAndSendTile( + String title, + String description, + IconData icon, { + required ShowAndSend value, + required Settings Function(ShowAndSend value) update, + }) => DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: value, + options: ShowAndSend.values, + getName: (option) => switch (option) { + .showAndSend => "Show and send", + .show => "Only show", + .neither => "Neither", + }, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(update(value)) + .onError(showError), + ); + return .new({ "General": .new([ .new( @@ -192,23 +217,25 @@ class SettingsSectionsController title: "Typing Indicators", description: "Show when others are typing, and let others see when you're typing.", icon: Icons.keyboard, - builder: (title, description, icon) => - DialogListTile( - icon: Icon(icon), - title: title, - subtitle: Text(description), - value: settings.typingIndicators, - options: TypingIndicators.values, - getName: (option) => switch (option) { - .showAndSend => "Show and send", - .show => "Only show", - .neither => "Neither", - }, - onChanged: (value) => ref - .read(SettingsController.provider.notifier) - .set(settings.copyWith(typingIndicators: value)) - .onError(showError), - ), + builder: (title, description, icon) => showAndSendTile( + title, + description, + icon, + value: settings.typingIndicators, + update: (value) => settings.copyWith(typingIndicators: value), + ), + ), + .new( + title: "Read Receipts", + description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.", + icon: Icons.done_all, + builder: (title, description, icon) => showAndSendTile( + title, + description, + icon, + value: settings.readReceipts, + update: (value) => settings.copyWith(readReceipts: value), + ), ), ]), ), diff --git a/lib/helpers/extensions/is_shown_as_message.dart b/lib/helpers/extensions/is_shown_as_message.dart new file mode 100644 index 0000000..a78810b --- /dev/null +++ b/lib/helpers/extensions/is_shown_as_message.dart @@ -0,0 +1,15 @@ +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/event.dart"; + +extension IsShownAsMessage on Event { + /// Whether this is shown in the timeline as a message, with MessageRenderer. + bool get isShownAsMessage => + redactedBy == null && + relationType != "m.replace" && + switch (content) { + MessageContent() || EncryptedContent() || StickerContent() => true, + _ => false, + }; +} diff --git a/lib/models/paginate.dart b/lib/models/paginate.dart index dc6eede..b7c6bf8 100644 --- a/lib/models/paginate.dart +++ b/lib/models/paginate.dart @@ -1,6 +1,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/read_receipt.dart"; part "paginate.freezed.dart"; part "paginate.g.dart"; @@ -10,6 +11,9 @@ part "paginate.g.dart"; class const Paginate({ required final IList events, required final IList relatedEvents, + + /// Read receipts for the events, by event ID. + final IMap> receipts = const .empty(), required final bool hasMore, }) with _$Paginate { Map toJson() => _$PaginateToJson(this); diff --git a/lib/models/read_receipt.dart b/lib/models/read_receipt.dart index ab4edd8..4d62635 100644 --- a/lib/models/read_receipt.dart +++ b/lib/models/read_receipt.dart @@ -13,6 +13,11 @@ class const ReadReceipt({ required final String eventId, @EpochDateTimeConverter() required final DateTime timestamp, }) with _$ReadReceipt { + String get thread => switch (threadId) { + final threadId? when threadId.isNotEmpty => threadId, + _ => "main", + }; + Map toJson() => _$ReadReceiptToJson(this); factory ReadReceipt.fromJson(Map json) => diff --git a/lib/models/settings.dart b/lib/models/settings.dart index e1e1bbc..7ea61d9 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,7 +1,7 @@ import "package:material_ui/material_ui.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/room_order.dart"; -import "package:nexus/models/typing_indicators.dart"; +import "package:nexus/models/show_and_send.dart"; part "settings.freezed.dart"; part "settings.g.dart"; @@ -17,7 +17,11 @@ class const Settings({ final bool swipeToReply = false, final bool hideMembershipEvents = false, final RoomOrder roomOrder = .unreadFirst, - final TypingIndicators typingIndicators = .showAndSend, + final ShowAndSend typingIndicators = .showAndSend, + + /// When not sending, read receipts are still sent privately, so that the + /// user's other devices know what has been read. + final ShowAndSend readReceipts = .showAndSend, }) with _$Settings { Map toJson() => _$SettingsToJson(this); diff --git a/lib/models/show_and_send.dart b/lib/models/show_and_send.dart new file mode 100644 index 0000000..f9663cc --- /dev/null +++ b/lib/models/show_and_send.dart @@ -0,0 +1,2 @@ +/// Whether to show something from others, and send it to them, e.g. typing indicators or read receipts. +enum ShowAndSend { showAndSend, show, neither } diff --git a/lib/models/typing_indicators.dart b/lib/models/typing_indicators.dart deleted file mode 100644 index 28df4db..0000000 --- a/lib/models/typing_indicators.dart +++ /dev/null @@ -1 +0,0 @@ -enum TypingIndicators { showAndSend, show, neither } diff --git a/lib/widgets/avatar_ring.dart b/lib/widgets/avatar_ring.dart new file mode 100644 index 0000000..f410978 --- /dev/null +++ b/lib/widgets/avatar_ring.dart @@ -0,0 +1,39 @@ +import "package:material_ui/material_ui.dart"; + +class const AvatarRing({ + required final String tooltip, + required final VoidCallback onTap, + required final Widget child, + final double dimension = 24, + super.key, +}) extends StatelessWidget { + static const ringWidth = 1.5; + + @override + Widget build(BuildContext context) => Tooltip( + message: tooltip, + child: Container( + padding: .all(ringWidth), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: .circle, + ), + child: ClipOval( + child: SizedBox.square( + dimension: dimension, + child: Stack( + fit: .expand, + children: [ + child, + // Over the child, so the ripple shows on top of avatars + Material( + type: .transparency, + child: InkWell(onTap: onTap), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/read_receipts_row.dart b/lib/widgets/read_receipts_row.dart new file mode 100644 index 0000000..e3f206f --- /dev/null +++ b/lib/widgets/read_receipts_row.dart @@ -0,0 +1,112 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/read_receipts.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatar_ring.dart"; +import "package:nexus/widgets/read_receipts_sheet.dart"; + +class const ReadReceiptsRow( + final String eventId, { + required final ChatParams chat, + super.key, +}) extends ConsumerWidget { + static const avatarSize = 24.0; + static const overlap = 6.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final receipts = ref.watch( + ReadReceiptsController.provider(chat) + .select((receipts) => receipts[eventId] ?? const .empty()), + ); + + return AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + alignment: .topRight, + child: receipts.isEmpty + ? const SizedBox(width: .infinity) + : Padding( + // Clear of the scrollbar + padding: .only(right: 16, bottom: 2), + child: LayoutBuilder( + builder: (context, constraints) { + const width = avatarSize + AvatarRing.ringWidth * 2; + final fits = + (constraints.maxWidth - width) ~/ (width - overlap) + 1; + + // If they don't all fit, the last spot shows how many more there are + final shown = receipts.length <= fits + ? receipts.length + : fits - 1; + + return Align( + alignment: .centerRight, + child: Wrap( + spacing: -overlap, + children: [ + ...receipts + .take(shown) + .map( + (receipt) => Consumer( + builder: (context, ref, _) { + final member = ref + .watch( + UserController.provider( + .new( + roomId: chat.roomId, + userId: receipt.userId, + ), + ), + ) + .value; + final name = + member?.displayName ?? + receipt.userId.localpart; + + return AvatarRing( + tooltip: name, + onTap: () => context.showUserPopover( + receipt.userId, + roomId: chat.roomId, + ), + child: AvatarOrHash( + member?.avatarUrl, + name, + dimension: avatarSize, + ), + ); + }, + ), + ), + if (receipts.length > shown) + AvatarRing( + tooltip: "Show everyone who has read up to here", + onTap: () => context.showAdaptiveSheet( + (_) => ReadReceiptsSheet(eventId, chat: chat), + ), + child: ColoredBox( + color: theme.colorScheme.surfaceContainerHighest, + child: Center( + child: Text( + "+${receipts.length - shown}", + style: theme.textTheme.labelSmall, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/read_receipts_sheet.dart b/lib/widgets/read_receipts_sheet.dart new file mode 100644 index 0000000..60793c8 --- /dev/null +++ b/lib/widgets/read_receipts_sheet.dart @@ -0,0 +1,55 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/read_receipts.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Everyone who has read up to an event, kept up to date while open. +class const ReadReceiptsSheet( + final String eventId, { + required final ChatParams chat, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => SheetLayout( + title: Text("Read by"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")), + ], + children: ref + .watch( + ReadReceiptsController.provider(chat) + .select((receipts) => receipts[eventId] ?? const .empty()), + ) + .map( + (receipt) => Consumer( + builder: (context, ref, _) { + final member = ref + .watch( + UserController.provider( + .new(roomId: chat.roomId, userId: receipt.userId), + ), + ) + .value; + final name = member?.displayName ?? receipt.userId.localpart; + + return ListTile( + contentPadding: .zero, + leading: AvatarOrHash(member?.avatarUrl, name, dimension: 40), + title: Text(name, overflow: .ellipsis), + subtitle: Text(receipt.userId, overflow: .ellipsis), + onTap: () => context.showUserPopover( + receipt.userId, + roomId: chat.roomId, + ), + ); + }, + ), + ) + .toList(), + ); +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index d1be9dd..47750fa 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -7,27 +7,26 @@ import "package:nexus/controllers/event.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/settings.dart"; +import "package:nexus/helpers/extensions/is_shown_as_message.dart"; import "package:nexus/helpers/extensions/show_adaptive_menu.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; -import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; -import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/server_acl.dart"; -import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/reaction_row.dart"; +import "package:nexus/widgets/read_receipts_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/swipe_to_reply.dart"; @@ -44,6 +43,10 @@ class const EventRenderer( /// If set, events can be swiped, calling this final VoidCallback? onSwipe, + + /// If set, shows who in this chat has read up to this event, e.g. in the + /// timeline. + final ChatParams? readReceiptsChat, super.key, }) extends HookConsumerWidget { @override @@ -96,9 +99,7 @@ class const EventRenderer( } final isGrouped = - previousEvent?.content is MessageContent && - previousEvent?.redactedBy == null && - previousEvent?.relationType != "m.replace" && + (previousEvent?.isShownAsMessage ?? false) && event.sender == previousEvent?.sender && event.pmp?.id == previousEvent?.pmp?.id; @@ -111,7 +112,26 @@ class const EventRenderer( TextSpan(text: text), ]); - final child = event.redactedBy != null || event.relationType == "m.replace" + final child = event.isShownAsMessage + ? MessageRenderer( + event, + 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.rowId); + } + }, + isGrouped: isGrouped, + maxLines: maxLines, + textOnly: textOnly, + ) + : event.redactedBy != null || event.relationType == "m.replace" ? null : switch (event.content) { Content(:final parseError?, :final stackTrace) => Row( @@ -123,27 +143,6 @@ class const EventRenderer( ], ), - MessageContent() || - EncryptedContent() || - StickerContent() => MessageRenderer( - event, - 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.rowId); - } - }, - isGrouped: isGrouped, - maxLines: maxLines, - textOnly: textOnly, - ), - MembershipContent content => switch (event.previousContent) { MembershipContent(:final status) => status == content.status @@ -285,6 +284,10 @@ class const EventRenderer( ...[ if (child is! MessageRenderer) ReactionRow(event), + // Outside the message itself, so it doesn't move when swiped + if (readReceiptsChat case final chat? when event.isShownAsMessage) + ReadReceiptsRow(event.eventId, chat: chat), + if (event.sendError != null && event.sendError != "not sent") Padding( padding: .only(bottom: 4), diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 2de327c..c949344 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -1,5 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_chat.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/renderers/event.dart"; @@ -7,7 +8,7 @@ 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 ChatParams chat, required final ChatScroll scroll, required final JumpToEvent jumpToEvent, required final IList Function(Event) getEventOptions, @@ -27,10 +28,11 @@ class const ChatTimeline({ EventRenderer( eventRowId, previousEventRowId: previousEventRowId, - roomId: roomId, + roomId: chat.roomId, jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), + readReceiptsChat: chat, ), key: key, isHighlighted: highlightedEvent == eventRowId, diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 7e807da..dd231f2 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -85,10 +85,11 @@ final class const RoomChat({ ), ); - final controllerProvider = RoomChatController.provider(( + final ChatParams chat = ( roomId: roomId, contextualEvent: contextualEvent.value, - )); + ); + final controllerProvider = RoomChatController.provider(chat); final notifier = ref.watch(controllerProvider.notifier); final client = ref.read(ClientController.provider.notifier); @@ -246,7 +247,7 @@ final class const RoomChat({ AsyncLoading(value: _?) || AsyncData(value: _) => ChatTimeline( scroll: scroll, - roomId: roomId, + chat: chat, jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, onSwipe: swipeToReply && canReply