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:ffi/ffi.dart";
import "package:flutter/cupertino.dart"; import "package:flutter/cupertino.dart";
import "package:intl/intl.dart"; import "package:intl/intl.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/models/capabilities.dart"; import "package:nexus/models/capabilities.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
@ -106,39 +107,50 @@ class ClientController extends AsyncNotifier<int> {
return handle; return handle;
} }
/// Calls gomuks with [data] as JSON, returning its decoded response.
Future<dynamic> callGomuksMethod( Future<dynamic> callGomuksMethod(
Map<String, dynamic> data, Map<String, dynamic> data,
FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data) void Function(
callback, int handle,
GomuksOwnedBuffer data,
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
)
call,
) async { ) 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 { try {
final handle = await future; call(handle, buffer, callable.nativeFunction);
} catch (_) {
final response = await Isolate.run( // gomuks never got the call, so it won't respond or free the buffer
() => callback(handle, bufferPointer.ref), callable.close();
); GomuksFreeBuffer(buffer);
rethrow;
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);
} }
return completer.future;
} }
Future<PushNotification?> handlePush(Map<String, dynamic> data) async { Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod( final response = await callGomuksMethod(data, GomuksHandlePushAsync);
data,
(handle, data) async => GomuksHandlePush(handle, data),
);
return response?["notification"] == null return response?["notification"] == null
? null ? null
@ -148,11 +160,12 @@ class ClientController extends AsyncNotifier<int> {
Future<dynamic> _sendCommand( Future<dynamic> _sendCommand(
String command, [ String command, [
Map<String, dynamic> data = const {}, Map<String, dynamic> data = const {},
]) => callGomuksMethod(data, (handle, data) { ]) => callGomuksMethod(data, (handle, data, callback) {
final commandPointer = command.toNativeUtf8().cast<Char>(); final commandPointer = command.toNativeUtf8().cast<Char>();
try { try {
return GomuksSubmitCommand(handle, commandPointer, data); GomuksSubmitCommandAsync(handle, commandPointer, data, callback);
} finally { } finally {
// gomuks copies the command name before returning
calloc.free(commandPointer); calloc.free(commandPointer);
} }
}); });
@ -332,9 +345,14 @@ class ClientController extends AsyncNotifier<int> {
if (event == null) return; 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", { await _sendCommand("mark_read", {
"room_id": room.metadata!.id, "room_id": room.metadata!.id,
"receipt_type": "m.read", "receipt_type": sendPublic ? "m.read" : "m.read.private",
"event_id": event.eventId, "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, valueMapper: (event) => event,
), ),
hasMore: response.hasMore, hasMore: response.hasMore,
receipts: response.receipts,
timeline: IMap.fromIterable( timeline: IMap.fromIterable(
response.events, response.events,
keyMapper: (event) => event.timelineRowId, 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:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/read_receipt.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class RoomsController extends Notifier<IMap<String, Room>> { class RoomsController extends Notifier<IMap<String, Room>> {
@ -33,6 +34,26 @@ class RoomsController extends Notifier<IMap<String, Room>> {
.new(), .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. /// Adds events to their rooms, without changing the rooms' timelines.
void storeEvents(Iterable<Event> events) => update( void storeEvents(Iterable<Event> events) => update(
.new( .new(
@ -96,15 +117,11 @@ class RoomsController extends Notifier<IMap<String, Room>> {
timeline: incoming.reset timeline: incoming.reset
? incoming.timeline ? incoming.timeline
: existing.timeline.addAll(incoming.timeline), : existing.timeline.addAll(incoming.timeline),
receipts: incoming.receipts.entries.fold( receipts: mergeReceipts(existing.receipts, incoming.receipts),
existing.receipts,
(receiptAcc, event) => receiptAcc.add(
event.key,
(receiptAcc[event.key] ?? .new()).addAll(event.value),
),
),
) ?? ) ??
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/helpers/extensions/distributor_name.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/room_order.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/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/main.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart";
@ -32,6 +33,30 @@ class SettingsSectionsController
Future<IMap<String, IList<SettingsCategory>>> build() async { Future<IMap<String, IList<SettingsCategory>>> build() async {
final settings = await ref.watch(SettingsController.provider.future); 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({ return .new({
"General": .new([ "General": .new([
.new( .new(
@ -192,23 +217,25 @@ class SettingsSectionsController
title: "Typing Indicators", title: "Typing Indicators",
description: "Show when others are typing, and let others see when you're typing.", description: "Show when others are typing, and let others see when you're typing.",
icon: Icons.keyboard, icon: Icons.keyboard,
builder: (title, description, icon) => builder: (title, description, icon) => showAndSendTile(
DialogListTile<TypingIndicators>( title,
icon: Icon(icon), description,
title: title, icon,
subtitle: Text(description), value: settings.typingIndicators,
value: settings.typingIndicators, update: (value) => settings.copyWith(typingIndicators: value),
options: TypingIndicators.values, ),
getName: (option) => switch (option) { ),
.showAndSend => "Show and send", .new(
.show => "Only show", title: "Read Receipts",
.neither => "Neither", 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,
onChanged: (value) => ref builder: (title, description, icon) => showAndSendTile(
.read(SettingsController.provider.notifier) title,
.set(settings.copyWith(typingIndicators: value)) description,
.onError(showError), 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> { 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() { Pointer<GomuksBorrowedBuffer> toGomuksBufferPtr() {
final jsonString = json.encode(this); final bytes = utf8.encode(json.encode(this));
final bytes = utf8.encode(jsonString);
final dataPtr = calloc<Uint8>(bytes.length); final dataPtr = calloc<Uint8>(bytes.length);
dataPtr.asTypedList(bytes.length).setAll(0, bytes); dataPtr.asTypedList(bytes.length).setAll(0, bytes);
final ptr = calloc<GomuksBorrowedBuffer>(); return calloc<GomuksBorrowedBuffer>()
..ref.base = dataPtr
ptr.ref ..ref.length = bytes.length;
..base = dataPtr
..length = bytes.length;
return ptr;
} }
} }

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:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/read_receipt.dart";
part "paginate.freezed.dart"; part "paginate.freezed.dart";
part "paginate.g.dart"; part "paginate.g.dart";
@ -10,6 +11,9 @@ part "paginate.g.dart";
class const Paginate({ class const Paginate({
required final IList<Event> events, required final IList<Event> events,
required final IList<Event> relatedEvents, 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, required final bool hasMore,
}) with _$Paginate { }) with _$Paginate {
Map<String, Object?> toJson() => _$PaginateToJson(this); Map<String, Object?> toJson() => _$PaginateToJson(this);

View file

@ -13,6 +13,11 @@ class const ReadReceipt({
required final String eventId, required final String eventId,
@EpochDateTimeConverter() required final DateTime timestamp, @EpochDateTimeConverter() required final DateTime timestamp,
}) with _$ReadReceipt { }) with _$ReadReceipt {
String get thread => switch (threadId) {
final threadId? when threadId.isNotEmpty => threadId,
_ => "main",
};
Map<String, Object?> toJson() => _$ReadReceiptToJson(this); Map<String, Object?> toJson() => _$ReadReceiptToJson(this);
factory ReadReceipt.fromJson(Map<String, Object?> json) => factory ReadReceipt.fromJson(Map<String, Object?> json) =>

View file

@ -1,7 +1,7 @@
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room_order.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.freezed.dart";
part "settings.g.dart"; part "settings.g.dart";
@ -17,7 +17,11 @@ class const Settings({
final bool swipeToReply = false, final bool swipeToReply = false,
final bool hideMembershipEvents = false, final bool hideMembershipEvents = false,
final RoomOrder roomOrder = .unreadFirst, 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 { }) with _$Settings {
Map<String, Object?> toJson() => _$SettingsToJson(this); 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/room_chat.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.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/extensions/show_adaptive_menu.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.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/history_visibility.dart";
import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.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/pinned_events.dart";
import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/content/server_acl.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/content/topic.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/renderers/message.dart";
import "package:nexus/widgets/reaction_row.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/membership.dart";
import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/swipe_to_reply.dart"; import "package:nexus/widgets/swipe_to_reply.dart";
@ -44,6 +43,10 @@ class const EventRenderer(
/// If set, events can be swiped, calling this /// If set, events can be swiped, calling this
final VoidCallback? onSwipe, 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, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
@ -96,9 +99,7 @@ class const EventRenderer(
} }
final isGrouped = final isGrouped =
previousEvent?.content is MessageContent && (previousEvent?.isShownAsMessage ?? false) &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType != "m.replace" &&
event.sender == previousEvent?.sender && event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id; event.pmp?.id == previousEvent?.pmp?.id;
@ -111,7 +112,26 @@ class const EventRenderer(
TextSpan(text: text), 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 ? null
: switch (event.content) { : switch (event.content) {
Content(:final parseError?, :final stackTrace) => Row( 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 content => switch (event.previousContent) {
MembershipContent(:final status) => MembershipContent(:final status) =>
status == content.status status == content.status
@ -285,6 +284,10 @@ class const EventRenderer(
...[ ...[
if (child is! MessageRenderer) ReactionRow(event), 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") if (event.sendError != null && event.sendError != "not sent")
Padding( Padding(
padding: .only(bottom: 4), padding: .only(bottom: 4),

View file

@ -1,5 +1,6 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.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/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/widgets/renderers/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"; import "package:super_sliver_list/super_sliver_list.dart";
class const ChatTimeline({ class const ChatTimeline({
required final String roomId, required final ChatParams chat,
required final ChatScroll scroll, required final ChatScroll scroll,
required final JumpToEvent jumpToEvent, required final JumpToEvent jumpToEvent,
required final IList<PopupMenuEntry> Function(Event) getEventOptions, required final IList<PopupMenuEntry> Function(Event) getEventOptions,
@ -27,10 +28,11 @@ class const ChatTimeline({
EventRenderer( EventRenderer(
eventRowId, eventRowId,
previousEventRowId: previousEventRowId, previousEventRowId: previousEventRowId,
roomId: roomId, roomId: chat.roomId,
jumpToEvent: jumpToEvent, jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
readReceiptsChat: chat,
), ),
key: key, key: key,
isHighlighted: highlightedEvent == eventRowId, isHighlighted: highlightedEvent == eventRowId,

View file

@ -85,10 +85,11 @@ final class const RoomChat({
), ),
); );
final controllerProvider = RoomChatController.provider(( final ChatParams chat = (
roomId: roomId, roomId: roomId,
contextualEvent: contextualEvent.value, contextualEvent: contextualEvent.value,
)); );
final controllerProvider = RoomChatController.provider(chat);
final notifier = ref.watch(controllerProvider.notifier); final notifier = ref.watch(controllerProvider.notifier);
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
@ -246,7 +247,7 @@ final class const RoomChat({
AsyncLoading(value: _?) || AsyncLoading(value: _?) ||
AsyncData(value: _) => ChatTimeline( AsyncData(value: _) => ChatTimeline(
scroll: scroll, scroll: scroll,
roomId: roomId, chat: chat,
jumpToEvent: jumpToEvent, jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
onSwipe: swipeToReply && canReply onSwipe: swipeToReply && canReply