From 21abc530cfaac0a42d62f0a4f421407995d322df Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 14:01:24 +0100 Subject: [PATCH] fix tablet navigation and reaction rollback Signed-off-by: kenny lopez --- .../lib/features/activity/activity_page.dart | 20 +++--- .../channels/channel_messages_provider.dart | 16 +++++ .../pending_local_aux_events_provider.dart | 42 +++++++++++++ .../home/home_page/wide_navigation.dart | 16 +++-- .../channel_messages_provider_test.dart | 62 +++++++++++++++++++ 5 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 mobile/lib/features/channels/pending_local_aux_events_provider.dart diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 2941fdfb2..d60b97984 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -421,7 +421,7 @@ class ActivityPage extends HookConsumerWidget { } } -class _WideActivityDetail extends StatelessWidget { +class _WideActivityDetail extends HookWidget { final InboxItem? item; final Channel? channel; final String? initialMessageId; @@ -440,13 +440,17 @@ class _WideActivityDetail extends StatelessWidget { return const _WideActivityEmptyDetail(); } - return Navigator( - key: ValueKey('wide-activity-detail-${item!.id}'), - onGenerateRoute: (_) => MaterialPageRoute( - builder: (_) => ChannelDetailPage( - channel: channel!, - initialMessageId: initialMessageId, - initialThreadRootId: initialThreadRootId, + final navigatorKey = useMemoized(GlobalKey.new, [item!.id]); + return NavigatorPopHandler( + onPopWithResult: (_) => navigatorKey.currentState?.maybePop(), + child: Navigator( + key: navigatorKey, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => ChannelDetailPage( + channel: channel!, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, + ), ), ), ); diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index b9b1ec2d6..e6bc73c5b 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; import 'pending_local_messages_provider.dart'; +import 'pending_local_aux_events_provider.dart'; import 'channel_window.dart'; import 'thread_replies_provider.dart'; @@ -105,6 +106,7 @@ class ChannelMessagesNotifier extends Notifier>> { final history = await _fetchNewestHistory(session); if (!_isCurrentInit(initVersion)) return; _confirmLocalMessages(history.map((event) => event.id)); + _confirmLocalAuxEvents(history.map((event) => event.id)); final existing = state.value ?? const []; final existingIds = existing.map((event) => event.id).toSet(); @@ -213,6 +215,9 @@ class ChannelMessagesNotifier extends Notifier>> { if (authoritative && event.threadReference.parentId == null) { _confirmLocalMessages([event.id]); } + if (authoritative && EventKind.channelAuxEventKinds.contains(event.kind)) { + _confirmLocalAuxEvents([event.id]); + } if (_usingChannelWindow) { _handleWindowLiveEvent(event); } else { @@ -282,6 +287,12 @@ class ChannelMessagesNotifier extends Notifier>> { .confirm(eventIds); } + void _confirmLocalAuxEvents(Iterable eventIds) { + ref + .read(pendingLocalAuxEventsProvider(channelId).notifier) + .confirm(eventIds); + } + /// Adds a just-signed outgoing message before the relay acknowledges it. /// The live relay echo is deduplicated by event id. void addLocalMessage(NostrEvent event) { @@ -327,11 +338,16 @@ class ChannelMessagesNotifier extends Notifier>> { 'Expected a channel auxiliary event.', ); } + ref.read(pendingLocalAuxEventsProvider(channelId).notifier).add(event); _handleLiveEvent(event, authoritative: false); } /// Removes an optimistic auxiliary event when its publish is rejected. void removeLocalAuxEvent(String eventId) { + final pending = ref + .read(pendingLocalAuxEventsProvider(channelId).notifier) + .take(eventId); + if (pending == null) return; final nextAux = _windowStore.liveAux .where((event) => event.id != eventId) .toList(); diff --git a/mobile/lib/features/channels/pending_local_aux_events_provider.dart b/mobile/lib/features/channels/pending_local_aux_events_provider.dart new file mode 100644 index 000000000..bef28c72b --- /dev/null +++ b/mobile/lib/features/channels/pending_local_aux_events_provider.dart @@ -0,0 +1,42 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; + +/// Signed local auxiliary events whose publish has not yet been corroborated +/// by an authoritative relay EVENT or query result. +class PendingLocalAuxEventsNotifier extends Notifier> { + final String channelId; + + PendingLocalAuxEventsNotifier(this.channelId); + + @override + Map build() => const {}; + + void add(NostrEvent event) { + state = {...state, event.id: event}; + } + + NostrEvent? take(String eventId) { + final event = state[eventId]; + if (event == null) return null; + final next = {...state}..remove(eventId); + state = next; + return event; + } + + void confirm(Iterable eventIds) { + final confirmed = eventIds.toSet(); + if (!state.keys.any(confirmed.contains)) return; + state = { + for (final entry in state.entries) + if (!confirmed.contains(entry.key)) entry.key: entry.value, + }; + } +} + +final pendingLocalAuxEventsProvider = + NotifierProvider.family< + PendingLocalAuxEventsNotifier, + Map, + String + >(PendingLocalAuxEventsNotifier.new); diff --git a/mobile/lib/features/home/home_page/wide_navigation.dart b/mobile/lib/features/home/home_page/wide_navigation.dart index eef056ffd..91b29a216 100644 --- a/mobile/lib/features/home/home_page/wide_navigation.dart +++ b/mobile/lib/features/home/home_page/wide_navigation.dart @@ -745,17 +745,23 @@ class _WideChannelDestination extends StatelessWidget { } } -class _WideChannelContent extends StatelessWidget { +class _WideChannelContent extends HookWidget { final Channel channel; const _WideChannelContent({required this.channel}); @override Widget build(BuildContext context) { - return Navigator( - key: ValueKey('wide-channel-detail-${channel.id}'), - onGenerateRoute: (_) => MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), + final navigatorKey = useMemoized(GlobalKey.new, [ + channel.id, + ]); + return NavigatorPopHandler( + onPopWithResult: (_) => navigatorKey.currentState?.maybePop(), + child: Navigator( + key: navigatorKey, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), ), ); } diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index 4f4efdce1..9be1fc008 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/channel_messages_provider.dart'; +import 'package:buzz/features/channels/pending_local_aux_events_provider.dart'; import 'package:buzz/features/channels/pending_local_messages_provider.dart'; import 'package:buzz/features/channels/thread_replies_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -248,6 +249,48 @@ void main() { }, ); + test( + 'authoritative auxiliary echo prevents a later publish rollback', + () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'message', createdAt: 10), _bounds()], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final reaction = _auxEvent( + id: 'reaction', + kind: EventKind.reaction, + createdAt: 20, + ); + + notifier.addLocalAuxEvent(reaction); + relaySession.emit(reaction); + await _pumpEventQueue(); + + expect( + container.read(pendingLocalAuxEventsProvider(_channelId)), + isEmpty, + ); + notifier.removeLocalAuxEvent(reaction.id); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['message', 'reaction'], + ); + }, + ); + test('adds and rolls back a local message in the channel window', () async { final relaySession = _RecordingRelaySessionNotifier( queryResults: [ @@ -663,6 +706,25 @@ NostrEvent _summary({ ); } +NostrEvent _auxEvent({ + required String id, + required int kind, + required int createdAt, +}) { + return NostrEvent( + id: id, + pubkey: 'alice', + createdAt: createdAt, + kind: kind, + tags: const [ + ['h', _channelId], + ['e', 'message'], + ], + content: '+', + sig: 'sig', + ); +} + NostrEvent _bounds({ bool hasMore = false, int? cursorCreatedAt,