fix tablet navigation and reaction rollback

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-03 14:01:24 +01:00
parent f552a95cc3
commit 21abc530cf
5 changed files with 143 additions and 13 deletions
@@ -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<void>(
builder: (_) => ChannelDetailPage(
channel: channel!,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
final navigatorKey = useMemoized(GlobalKey<NavigatorState>.new, [item!.id]);
return NavigatorPopHandler(
onPopWithResult: (_) => navigatorKey.currentState?.maybePop(),
child: Navigator(
key: navigatorKey,
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(
channel: channel!,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
),
),
),
);
@@ -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<AsyncValue<List<NostrEvent>>> {
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 <NostrEvent>[];
final existingIds = existing.map((event) => event.id).toSet();
@@ -213,6 +215,9 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
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<AsyncValue<List<NostrEvent>>> {
.confirm(eventIds);
}
void _confirmLocalAuxEvents(Iterable<String> 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<AsyncValue<List<NostrEvent>>> {
'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();
@@ -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<Map<String, NostrEvent>> {
final String channelId;
PendingLocalAuxEventsNotifier(this.channelId);
@override
Map<String, NostrEvent> 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<String> 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, NostrEvent>,
String
>(PendingLocalAuxEventsNotifier.new);
@@ -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<void>(
builder: (_) => ChannelDetailPage(channel: channel),
final navigatorKey = useMemoized(GlobalKey<NavigatorState>.new, [
channel.id,
]);
return NavigatorPopHandler(
onPopWithResult: (_) => navigatorKey.currentState?.maybePop(),
child: Navigator(
key: navigatorKey,
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
),
);
}
@@ -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,