make NotificationsController work off of rowIds

This commit is contained in:
Henry Hiles 2026-09-29 15:10:21 -04:00
commit ead463262c
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
12 changed files with 116 additions and 71 deletions

View file

@ -1,17 +1,16 @@
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart";
class ContextualEventController(String? _) extends Notifier<Event?> { class ContextualEventController(String? _) extends Notifier<int?> {
@override @override
Event? build() => null; int? build() => null;
void set(Event? event) => state = event; void set(int? event) => state = event;
@override @override
bool updateShouldNotify(_, _) => true; bool updateShouldNotify(_, _) => true;
static final provider = NotifierProvider.family static final provider = NotifierProvider.family
.autoDispose<ContextualEventController, Event?, String?>( .autoDispose<ContextualEventController, int?, String?>(
ContextualEventController.new, ContextualEventController.new,
); );
} }

View file

@ -31,12 +31,14 @@ class NotificationController
macOS: darwin, macOS: darwin,
), ),
onDidReceiveNotificationResponse: (details) { onDidReceiveNotificationResponse: (details) {
if (details.payload case final eventId?) { if (details.payload == null) return;
if (int.tryParse(details.payload!) case final rowId?) {
if (navigatorKey.currentContext case final context?) { if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => NotificationsPage( builder: (_) => NotificationsPage(
highlightedEventId: eventId, highlightedEvent: rowId,
defaultToAllNotifications: true, defaultToAllNotifications: true,
), ),
), ),
@ -55,18 +57,18 @@ class NotificationController
return; return;
} }
final eventId = call.arguments as String; if (int.tryParse(call.arguments) case int rowId) {
if (navigatorKey.currentContext case final context?) { if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => NotificationsPage( builder: (_) => NotificationsPage(
highlightedEventId: eventId, highlightedEvent: rowId,
defaultToAllNotifications: true, defaultToAllNotifications: true,
), ),
), ),
); );
} }
}
}); });
ref.onDispose(() => notificationChannel.setMethodCallHandler(null)); ref.onDispose(() => notificationChannel.setMethodCallHandler(null));

View file

@ -1,33 +1,61 @@
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/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId); typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController([final NotificationsRequest? request]) class NotificationsController([final NotificationsRequest? request])
extends AsyncNotifier<IList<Event>> { extends AsyncNotifier<IList<(int, String)>> {
static const virtualRoomId = "!notifications";
@override @override
Future<IList<Event>> build() async { Future<IList<(int, String)>> build() async {
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null); final (unreadType, roomId) = request ?? (null, null);
return await client.getMentions( final mentions = await client.getMentions(
.new( .new(
maxTimestamp: .now(), maxTimestamp: .now(),
unreadType: unreadType ?? .highlight, unreadType: unreadType ?? .highlight,
roomId: roomId, roomId: roomId,
), ),
); );
ref
.watch(RoomsController.provider.notifier)
.update(
.new({
virtualRoomId: .new(
events: IMap.fromIterable(
mentions,
keyMapper: (event) => event.rowId,
),
),
}),
.new(),
);
return .new(mentions.map((event) => (event.rowId, event.roomId)));
} }
Future<void> loadOlder() async { Future<void> loadOlder() async {
final currentNotifications = await future; final currentNotifications = await future;
state = .loading(); state = .loading();
state = await .guard(() async { state = await .guard(() async {
final lastTs = currentNotifications.lastOrNull?.timestamp; final lastNotification = currentNotifications.lastOrNull?.$1;
if (lastTs == null) return const .empty(); final lastTs = lastNotification == null
? null
: ref.watch(
RoomsController.provider.select(
(rooms) =>
rooms[virtualRoomId]?.events[lastNotification]?.timestamp,
),
);
if (lastTs == null) return currentNotifications;
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null); final (unreadType, roomId) = request ?? (null, null);
@ -40,14 +68,30 @@ class NotificationsController([final NotificationsRequest? request])
), ),
); );
return currentNotifications.addAll(newNotifications); ref
.watch(RoomsController.provider.notifier)
.update(
.new({
virtualRoomId: .new(
events: IMap.fromIterable(
newNotifications,
keyMapper: (event) => event.rowId,
),
),
}),
.new(),
);
return currentNotifications.addAll(
newNotifications.map((event) => (event.rowId, event.roomId)),
);
}); });
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose< .autoDispose<
NotificationsController, NotificationsController,
IList<Event>, IList<(int, String)>,
NotificationsRequest? NotificationsRequest?
>(NotificationsController.new); >(NotificationsController.new);
} }

View file

@ -18,10 +18,7 @@ import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/room_chat.dart"; import "package:nexus/models/room_chat.dart";
typedef ChatParams = ({ typedef ChatParams = ({String roomId, int? contextualEvent});
String roomId,
({int rowId, String eventId})? contextualEvent,
});
class RoomChatController(final ChatParams info) class RoomChatController(final ChatParams info)
extends AsyncNotifier<RoomChat?> { extends AsyncNotifier<RoomChat?> {
@ -47,7 +44,7 @@ class RoomChatController(final ChatParams info)
.nonNulls .nonNulls
.toIList(); .toIList();
if (info.contextualEvent == null || if (info.contextualEvent == null ||
timeline.contains(info.contextualEvent!.rowId)) { timeline.contains(info.contextualEvent)) {
ref.watch( ref.watch(
RoomsController.provider.select( RoomsController.provider.select(
(rooms) => (rooms) =>
@ -62,7 +59,10 @@ class RoomChatController(final ChatParams info)
); );
} else { } else {
final context = await client.getEventContext( final context = await client.getEventContext(
.new(roomId: info.roomId, eventId: info.contextualEvent!.eventId), .new(
roomId: info.roomId,
eventId: room.events[info.contextualEvent!]!.eventId,
),
); );
final events = context.before.add(context.event).addAll(context.after); final events = context.before.add(context.event).addAll(context.after);

View file

@ -91,7 +91,7 @@ class UnifiedPushController extends AsyncNotifier<bool> {
id: event.eventId.hashCode & 0x7fffffff, id: event.eventId.hashCode & 0x7fffffff,
title: roomMetadata.name ?? "New Event", title: roomMetadata.name ?? "New Event",
icon: icon, icon: icon,
payload: event.eventId, payload: event.rowId.toString(),
body: switch (event.content) { body: switch (event.content) {
MessageContent(:final body?) || MessageContent(:final body?) ||
StickerContent(:final body) => body, StickerContent(:final body) => body,

View file

@ -5,16 +5,17 @@ import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/models/direction.dart"; import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room_chat.dart"; import "package:nexus/models/room_chat.dart";
typedef JumpToEvent = Future<void> Function(int rowId);
final class ChatScroll({ final class ChatScroll({
required final IList<int> historyRows, required final IList<int> historyRows,
required final IList<int> liveRows, required final IList<int> liveRows,
required final GlobalKey centerKey, required final GlobalKey centerKey,
required final ScrollController scrollController, required final ScrollController scrollController,
required final bool atBottom, required final bool atBottom,
required final Future<void> Function(Event event) jumpToEvent, required final JumpToEvent jumpToEvent,
required final Future<void> Function() jumpToBottom, required final Future<void> Function() jumpToBottom,
required final GlobalKey Function(int eventRowId) keyFor, required final GlobalKey Function(int eventRowId) keyFor,
}) { }) {
@ -22,7 +23,7 @@ final class ChatScroll({
required AsyncValue<RoomChat?> controllerData, required AsyncValue<RoomChat?> controllerData,
required Future<void> Function(Direction direction) paginate, required Future<void> Function(Direction direction) paginate,
required Future<void> Function() markRead, required Future<void> Function() markRead,
required ValueNotifier<Event?> contextualEvent, required ValueNotifier<int?> contextualEvent,
}) { }) {
final anchorId = useState<int?>(null); final anchorId = useState<int?>(null);
@ -43,11 +44,11 @@ final class ChatScroll({
if (controllerData case AsyncData(:final value?) if (controllerData case AsyncData(:final value?)
when value.timeline.isNotEmpty) { when value.timeline.isNotEmpty) {
final hasContextualEvent = value.timeline.any( final hasContextualEvent = value.timeline.any(
(event) => event == contextualEvent.value?.rowId, (event) => event == contextualEvent.value,
); );
anchorId.value = hasContextualEvent anchorId.value = hasContextualEvent
? contextualEvent.value?.rowId ? contextualEvent.value
: value.timeline.last; : value.timeline.last;
} }
} }
@ -157,10 +158,10 @@ final class ChatScroll({
centerKey: centerKey, centerKey: centerKey,
scrollController: scrollController, scrollController: scrollController,
atBottom: atBottom.value, atBottom: atBottom.value,
jumpToEvent: (Event event) async { jumpToEvent: (int rowId) async {
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
final existing = keyFor(event.rowId).currentContext; final existing = keyFor(rowId).currentContext;
if (existing != null && existing.mounted) { if (existing != null && existing.mounted) {
// Already mounted, just scroll // Already mounted, just scroll
await Scrollable.ensureVisible( await Scrollable.ensureVisible(
@ -172,8 +173,8 @@ final class ChatScroll({
} else { } else {
final completer = Completer<BuildContext>(); final completer = Completer<BuildContext>();
anchorMountedCompleter.value = completer; anchorMountedCompleter.value = completer;
pendingAnchorTarget.value = event.rowId; pendingAnchorTarget.value = rowId;
contextualEvent.value = event; contextualEvent.value = rowId;
final context = await completer.future; final context = await completer.future;
if (!context.mounted) return; if (!context.mounted) return;

View file

@ -36,7 +36,7 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
key: ValueKey((roomId, initialHighlight)), key: ValueKey((roomId, initialHighlight)),
roomId: roomId, roomId: roomId,
isDesktop: isDesktop, isDesktop: isDesktop,
initialHighlightedEvent: initialHighlight, initialHighlight: initialHighlight,
showMembersByDefault: showMembersByDefault, showMembersByDefault: showMembersByDefault,
); );
}, },

View file

@ -16,7 +16,7 @@ import "package:nexus/widgets/renderers/event.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:super_sliver_list/super_sliver_list.dart";
class const NotificationsPage({ class const NotificationsPage({
final String? highlightedEventId, final int? highlightedEvent,
final bool defaultToAllNotifications = false, final bool defaultToAllNotifications = false,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@ -34,7 +34,7 @@ class const NotificationsPage({
}; };
final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0); final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0);
final highlightedId = useState(highlightedEventId); final highlightedId = useState(highlightedEvent);
final listController = useRef(ListController()); final listController = useRef(ListController());
final scrollController = useScrollController(); final scrollController = useScrollController();
@ -56,7 +56,7 @@ class const NotificationsPage({
final notifications = await ref.watch(provider.future); final notifications = await ref.watch(provider.future);
final index = notifications.indexWhere( final index = notifications.indexWhere(
(element) => element.eventId == highlightedId.value!, (tuple) => tuple.$1 == highlightedId.value!,
); );
if (index == -1) return; if (index == -1) return;
@ -120,23 +120,23 @@ class const NotificationsPage({
), ),
reverse: true, reverse: true,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final event = value[index]; final (rowId, roomId) = value[index];
final isHighlighted = final isHighlighted =
event.eventId == highlightedId.value; rowId == highlightedId.value;
return Padding( return Padding(
padding: .only(top: 8), padding: .only(top: 8),
child: HighlightWrapper( child: HighlightWrapper(
InkWell( InkWell(
onTap: () async { onTap: () async {
await ref.focusRoom(event.roomId); await ref.focusRoom(roomId);
ref ref
.watch( .watch(
ContextualEventController.provider( ContextualEventController.provider(
event.roomId, roomId,
).notifier, ).notifier,
) )
.set(event); .set(rowId);
if (context.mounted) { if (context.mounted) {
Navigator.of(context).pop(); Navigator.of(context).pop();
@ -144,8 +144,9 @@ class const NotificationsPage({
}, },
child: IgnorePointer( child: IgnorePointer(
child: EventRenderer( child: EventRenderer(
event.rowId, rowId,
roomId: event.roomId, roomId: NotificationsController
.virtualRoomId,
), ),
), ),
), ),

View file

@ -2,6 +2,7 @@ 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:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/pinned_events.dart"; import "package:nexus/controllers/pinned_events.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/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -10,7 +11,7 @@ import "package:nexus/widgets/renderers/event.dart";
final class const PinnedEventsDrawer( final class const PinnedEventsDrawer(
final String roomId, { final String roomId, {
required final IList<PopupMenuEntry> Function(Event event) getEventOptions, required final IList<PopupMenuEntry> Function(Event event) getEventOptions,
required final Future<void> Function(Event event) jumpToEvent, required final JumpToEvent jumpToEvent,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
@ -62,7 +63,7 @@ final class const PinnedEventsDrawer(
borderRadius: .circular(12), borderRadius: .circular(12),
onTap: () { onTap: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
jumpToEvent(event); jumpToEvent(event.rowId);
}, },
child: Padding( child: Padding(
padding: .symmetric(vertical: 4), padding: .symmetric(vertical: 4),

View file

@ -6,6 +6,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/event.dart"; import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/helpers/extensions/show_context_menu.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";
@ -35,7 +36,7 @@ class const EventRenderer(
final int? previousEventRowId, final int? previousEventRowId,
final bool textOnly = false, final bool textOnly = false,
final int? maxLines, final int? maxLines,
final Future<void> Function(Event event)? jumpToEvent, final JumpToEvent? jumpToEvent,
final IList<PopupMenuEntry> Function(Event event)? getEventOptions, final IList<PopupMenuEntry> Function(Event event)? getEventOptions,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@ -106,7 +107,8 @@ class const EventRenderer(
.new(roomId: roomId, eventId: event.replyTo!), .new(roomId: roomId, eventId: event.replyTo!),
).future, ).future,
); );
if (replyEvent != null) await jumpToEvent!(replyEvent); if (replyEvent != null)
await jumpToEvent!(replyEvent.rowId);
}, },
isGrouped: isGrouped, isGrouped: isGrouped,
maxLines: maxLines, maxLines: maxLines,

View file

@ -9,7 +9,7 @@ import "package:super_sliver_list/super_sliver_list.dart";
class const ChatTimeline({ class const ChatTimeline({
required final String roomId, required final String roomId,
required final ChatScroll scroll, required final ChatScroll scroll,
required final Future<void> Function(Event event) jumpToEvent, required final JumpToEvent jumpToEvent,
required final IList<PopupMenuEntry> Function(Event) getEventOptions, required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final int? highlightedEvent, required final int? highlightedEvent,
required final double composerHeight, required final double composerHeight,

View file

@ -24,14 +24,14 @@ final class const RoomChat({
required final String? roomId, required final String? roomId,
required final bool isDesktop, required final bool isDesktop,
required final bool showMembersByDefault, required final bool showMembersByDefault,
final Event? initialHighlightedEvent, final int? initialHighlight,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final relatedEvent = useState<Event?>(null); final relatedEvent = useState<Event?>(null);
final relationType = useState(RelationType.reply); final relationType = useState(RelationType.reply);
final contextualEvent = useState<Event?>(initialHighlightedEvent); final contextualEvent = useState<int?>(initialHighlight);
final highlightedEvent = useState<int?>(null); final highlightedEvent = useState<int?>(null);
final composerSize = useState<double>(64); final composerSize = useState<double>(64);
@ -63,12 +63,7 @@ final class const RoomChat({
final controllerProvider = RoomChatController.provider(( final controllerProvider = RoomChatController.provider((
roomId: roomId, roomId: roomId,
contextualEvent: contextualEvent.value == null contextualEvent: contextualEvent.value,
? null
: (
rowId: contextualEvent.value!.rowId,
eventId: contextualEvent.value!.eventId,
),
)); ));
final notifier = ref.watch(controllerProvider.notifier); final notifier = ref.watch(controllerProvider.notifier);
@ -102,25 +97,25 @@ final class const RoomChat({
}, },
); );
Future<void> jumpToEvent(Event event) async { Future<void> jumpToEvent(int rowId) async {
highlightedEvent.value = event.rowId; highlightedEvent.value = rowId;
await scroll.jumpToEvent(event); await scroll.jumpToEvent(rowId);
await Future.delayed(.new(milliseconds: 700), () { await Future.delayed(.new(milliseconds: 700), () {
if (highlightedEvent.value == event.rowId) { if (highlightedEvent.value == rowId) {
highlightedEvent.value = null; highlightedEvent.value = null;
} }
}); });
} }
useEffect(() { useEffect(() {
if (initialHighlightedEvent == null) return null; if (initialHighlight == null) return null;
void check() { void check() {
if (!context.mounted) return; if (!context.mounted) return;
if (scroll.scrollController.hasClients) { if (scroll.scrollController.hasClients) {
jumpToEvent(initialHighlightedEvent!); jumpToEvent(initialHighlight!);
} else { } else {
WidgetsBinding.instance.addPostFrameCallback((_) => check()); WidgetsBinding.instance.addPostFrameCallback((_) => check());
} }
@ -129,7 +124,7 @@ final class const RoomChat({
check(); check();
return null; return null;
}, [initialHighlightedEvent]); }, [initialHighlight]);
IList<PopupMenuEntry> getEventOptions(Event event) => IList<PopupMenuEntry> getEventOptions(Event event) =>
event.buildEventOptions( event.buildEventOptions(