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:nexus/models/event.dart";
class ContextualEventController(String? _) extends Notifier<Event?> {
class ContextualEventController(String? _) extends Notifier<int?> {
@override
Event? build() => null;
int? build() => null;
void set(Event? event) => state = event;
void set(int? event) => state = event;
@override
bool updateShouldNotify(_, _) => true;
static final provider = NotifierProvider.family
.autoDispose<ContextualEventController, Event?, String?>(
.autoDispose<ContextualEventController, int?, String?>(
ContextualEventController.new,
);
}

View file

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

View file

@ -1,33 +1,61 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/event.dart";
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController([final NotificationsRequest? request])
extends AsyncNotifier<IList<Event>> {
extends AsyncNotifier<IList<(int, String)>> {
static const virtualRoomId = "!notifications";
@override
Future<IList<Event>> build() async {
Future<IList<(int, String)>> build() async {
final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null);
return await client.getMentions(
final mentions = await client.getMentions(
.new(
maxTimestamp: .now(),
unreadType: unreadType ?? .highlight,
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 {
final currentNotifications = await future;
state = .loading();
state = await .guard(() async {
final lastTs = currentNotifications.lastOrNull?.timestamp;
if (lastTs == null) return const .empty();
final lastNotification = currentNotifications.lastOrNull?.$1;
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 (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
.autoDispose<
NotificationsController,
IList<Event>,
IList<(int, String)>,
NotificationsRequest?
>(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_chat.dart";
typedef ChatParams = ({
String roomId,
({int rowId, String eventId})? contextualEvent,
});
typedef ChatParams = ({String roomId, int? contextualEvent});
class RoomChatController(final ChatParams info)
extends AsyncNotifier<RoomChat?> {
@ -47,7 +44,7 @@ class RoomChatController(final ChatParams info)
.nonNulls
.toIList();
if (info.contextualEvent == null ||
timeline.contains(info.contextualEvent!.rowId)) {
timeline.contains(info.contextualEvent)) {
ref.watch(
RoomsController.provider.select(
(rooms) =>
@ -62,7 +59,10 @@ class RoomChatController(final ChatParams info)
);
} else {
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);

View file

@ -91,7 +91,7 @@ class UnifiedPushController extends AsyncNotifier<bool> {
id: event.eventId.hashCode & 0x7fffffff,
title: roomMetadata.name ?? "New Event",
icon: icon,
payload: event.eventId,
payload: event.rowId.toString(),
body: switch (event.content) {
MessageContent(:final 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:material_ui/material_ui.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room_chat.dart";
typedef JumpToEvent = Future<void> Function(int rowId);
final class ChatScroll({
required final IList<int> historyRows,
required final IList<int> liveRows,
required final GlobalKey centerKey,
required final ScrollController scrollController,
required final bool atBottom,
required final Future<void> Function(Event event) jumpToEvent,
required final JumpToEvent jumpToEvent,
required final Future<void> Function() jumpToBottom,
required final GlobalKey Function(int eventRowId) keyFor,
}) {
@ -22,7 +23,7 @@ final class ChatScroll({
required AsyncValue<RoomChat?> controllerData,
required Future<void> Function(Direction direction) paginate,
required Future<void> Function() markRead,
required ValueNotifier<Event?> contextualEvent,
required ValueNotifier<int?> contextualEvent,
}) {
final anchorId = useState<int?>(null);
@ -43,11 +44,11 @@ final class ChatScroll({
if (controllerData case AsyncData(:final value?)
when value.timeline.isNotEmpty) {
final hasContextualEvent = value.timeline.any(
(event) => event == contextualEvent.value?.rowId,
(event) => event == contextualEvent.value,
);
anchorId.value = hasContextualEvent
? contextualEvent.value?.rowId
? contextualEvent.value
: value.timeline.last;
}
}
@ -157,10 +158,10 @@ final class ChatScroll({
centerKey: centerKey,
scrollController: scrollController,
atBottom: atBottom.value,
jumpToEvent: (Event event) async {
jumpToEvent: (int rowId) async {
if (!scrollController.hasClients) return;
final existing = keyFor(event.rowId).currentContext;
final existing = keyFor(rowId).currentContext;
if (existing != null && existing.mounted) {
// Already mounted, just scroll
await Scrollable.ensureVisible(
@ -172,8 +173,8 @@ final class ChatScroll({
} else {
final completer = Completer<BuildContext>();
anchorMountedCompleter.value = completer;
pendingAnchorTarget.value = event.rowId;
contextualEvent.value = event;
pendingAnchorTarget.value = rowId;
contextualEvent.value = rowId;
final context = await completer.future;
if (!context.mounted) return;

View file

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

View file

@ -16,7 +16,7 @@ import "package:nexus/widgets/renderers/event.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const NotificationsPage({
final String? highlightedEventId,
final int? highlightedEvent,
final bool defaultToAllNotifications = false,
super.key,
}) extends HookConsumerWidget {
@ -34,7 +34,7 @@ class const NotificationsPage({
};
final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0);
final highlightedId = useState(highlightedEventId);
final highlightedId = useState(highlightedEvent);
final listController = useRef(ListController());
final scrollController = useScrollController();
@ -56,7 +56,7 @@ class const NotificationsPage({
final notifications = await ref.watch(provider.future);
final index = notifications.indexWhere(
(element) => element.eventId == highlightedId.value!,
(tuple) => tuple.$1 == highlightedId.value!,
);
if (index == -1) return;
@ -120,23 +120,23 @@ class const NotificationsPage({
),
reverse: true,
itemBuilder: (context, index) {
final event = value[index];
final (rowId, roomId) = value[index];
final isHighlighted =
event.eventId == highlightedId.value;
rowId == highlightedId.value;
return Padding(
padding: .only(top: 8),
child: HighlightWrapper(
InkWell(
onTap: () async {
await ref.focusRoom(event.roomId);
await ref.focusRoom(roomId);
ref
.watch(
ContextualEventController.provider(
event.roomId,
roomId,
).notifier,
)
.set(event);
.set(rowId);
if (context.mounted) {
Navigator.of(context).pop();
@ -144,8 +144,9 @@ class const NotificationsPage({
},
child: IgnorePointer(
child: EventRenderer(
event.rowId,
roomId: event.roomId,
rowId,
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:hooks_riverpod/hooks_riverpod.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/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
@ -10,7 +11,7 @@ import "package:nexus/widgets/renderers/event.dart";
final class const PinnedEventsDrawer(
final String roomId, {
required final IList<PopupMenuEntry> Function(Event event) getEventOptions,
required final Future<void> Function(Event event) jumpToEvent,
required final JumpToEvent jumpToEvent,
super.key,
}) extends HookConsumerWidget {
@override
@ -62,7 +63,7 @@ final class const PinnedEventsDrawer(
borderRadius: .circular(12),
onTap: () {
Navigator.of(context).pop();
jumpToEvent(event);
jumpToEvent(event.rowId);
},
child: Padding(
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/rooms.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/canonical_alias.dart";
import "package:nexus/models/content/content.dart";
@ -35,7 +36,7 @@ class const EventRenderer(
final int? previousEventRowId,
final bool textOnly = false,
final int? maxLines,
final Future<void> Function(Event event)? jumpToEvent,
final JumpToEvent? jumpToEvent,
final IList<PopupMenuEntry> Function(Event event)? getEventOptions,
super.key,
}) extends HookConsumerWidget {
@ -106,7 +107,8 @@ class const EventRenderer(
.new(roomId: roomId, eventId: event.replyTo!),
).future,
);
if (replyEvent != null) await jumpToEvent!(replyEvent);
if (replyEvent != null)
await jumpToEvent!(replyEvent.rowId);
},
isGrouped: isGrouped,
maxLines: maxLines,

View file

@ -9,7 +9,7 @@ import "package:super_sliver_list/super_sliver_list.dart";
class const ChatTimeline({
required final String roomId,
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 int? highlightedEvent,
required final double composerHeight,

View file

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