Compare commits

..
Author SHA1 Message Date
e38307657d
Read receipt support
Fixes #93
2026-10-21 22:58:06 -04:00
e14ab3e708
use *Async methods from gomuks
Performance improvement due to not needing as many Isolates.
2026-10-21 22:57:26 -04:00
19 changed files with 492 additions and 96 deletions

2
gomuks

@ -1 +1 @@
Subproject commit ba82b6e31c87c99766a8366a72a9f07d643b323f
Subproject commit a65299d327e3a533e6c476b0450b09be91fbb827

View file

@ -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";
@ -106,39 +107,50 @@ class ClientController extends AsyncNotifier<int> {
return handle;
}
/// Calls gomuks with [data] as JSON, returning its decoded response.
Future<dynamic> callGomuksMethod(
Map<String, dynamic> data,
FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data)
callback,
void Function(
int handle,
GomuksOwnedBuffer data,
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
)
call,
) async {
final bufferPointer = data.toGomuksBufferPtr();
final handle = await future;
final buffer = data.toGomuksOwnedBuffer();
final completer = Completer<dynamic>();
late final NativeCallable<Void Function(GomuksResponse)> callable;
callable = .listener((GomuksResponse response) {
callable.close();
try {
final json = response.buf.toJsonAndFree();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw GomuksException(json.toString());
}
completer.complete(json);
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
});
try {
final handle = await future;
final response = await Isolate.run(
() => callback(handle, bufferPointer.ref),
);
final json = response.buf.toJsonAndFree();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw GomuksException(json.toString());
}
return json;
} finally {
calloc
..free(bufferPointer.ref.base)
..free(bufferPointer);
call(handle, buffer, callable.nativeFunction);
} catch (_) {
// gomuks never got the call, so it won't respond or free the buffer
callable.close();
GomuksFreeBuffer(buffer);
rethrow;
}
return completer.future;
}
Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod(
data,
(handle, data) async => GomuksHandlePush(handle, data),
);
final response = await callGomuksMethod(data, GomuksHandlePushAsync);
return response?["notification"] == null
? null
@ -148,11 +160,12 @@ class ClientController extends AsyncNotifier<int> {
Future<dynamic> _sendCommand(
String command, [
Map<String, dynamic> data = const {},
]) => callGomuksMethod(data, (handle, data) {
]) => callGomuksMethod(data, (handle, data, callback) {
final commandPointer = command.toNativeUtf8().cast<Char>();
try {
return GomuksSubmitCommand(handle, commandPointer, data);
GomuksSubmitCommandAsync(handle, commandPointer, data, callback);
} finally {
// gomuks copies the command name before returning
calloc.free(commandPointer);
}
});
@ -332,9 +345,14 @@ class ClientController extends AsyncNotifier<int> {
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,
});
}

View file

@ -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<IMap<String, IList<ReadReceipt>>> {
@override
IMap<String, IList<ReadReceipt>> 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<String, IList<ReadReceipt>>,
ChatParams
>(ReadReceiptsController.new);
}

View file

@ -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,

View file

@ -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<IMap<String, Room>> {
@ -33,6 +34,26 @@ class RoomsController extends Notifier<IMap<String, Room>> {
.new(),
);
static IMap<String, IList<ReadReceipt>> mergeReceipts(
IMap<String, IList<ReadReceipt>> existing,
IMap<String, IList<ReadReceipt>> 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<Event> events) => update(
.new(
@ -96,15 +117,11 @@ class RoomsController extends Notifier<IMap<String, Room>> {
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),
),
);
});

View file

@ -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<IMap<String, IList<SettingsCategory>>> 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<ShowAndSend>(
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<TypingIndicators>(
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),
),
),
]),
),

View file

@ -19,19 +19,21 @@ extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
}
extension JsonToGomuksBuffer on Map<String, dynamic> {
GomuksOwnedBuffer toGomuksOwnedBuffer() {
final bytes = utf8.encode(json.encode(this));
final buffer = GomuksAllocBuffer(bytes.length);
buffer.base.asTypedList(bytes.length).setAll(0, bytes);
return buffer;
}
Pointer<GomuksBorrowedBuffer> toGomuksBufferPtr() {
final jsonString = json.encode(this);
final bytes = utf8.encode(jsonString);
final bytes = utf8.encode(json.encode(this));
final dataPtr = calloc<Uint8>(bytes.length);
dataPtr.asTypedList(bytes.length).setAll(0, bytes);
final ptr = calloc<GomuksBorrowedBuffer>();
ptr.ref
..base = dataPtr
..length = bytes.length;
return ptr;
return calloc<GomuksBorrowedBuffer>()
..ref.base = dataPtr
..ref.length = bytes.length;
}
}

View file

@ -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,
};
}

View file

@ -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<Event> events,
required final IList<Event> relatedEvents,
/// Read receipts for the events, by event ID.
final IMap<String, IList<ReadReceipt>> receipts = const .empty(),
required final bool hasMore,
}) with _$Paginate {
Map<String, Object?> toJson() => _$PaginateToJson(this);

View file

@ -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<String, Object?> toJson() => _$ReadReceiptToJson(this);
factory ReadReceipt.fromJson(Map<String, Object?> json) =>

View file

@ -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<String, Object?> toJson() => _$SettingsToJson(this);

View file

@ -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 }

View file

@ -1 +0,0 @@
enum TypingIndicators { showAndSend, show, neither }

View file

@ -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),
),
],
),
),
),
),
);
}

View file

@ -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,
),
),
),
),
],
),
);
},
),
),
);
}
}

View file

@ -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(),
);
}

View file

@ -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),

View file

@ -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<PopupMenuEntry> 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,

View file

@ -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