Read receipt support

Fixes #93
This commit is contained in:
Henry Hiles 2026-10-21 22:58:06 -04:00
commit e38307657d
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 445 additions and 63 deletions

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