mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
Resolve Solo routing, offline organization and abandoned chat recovery
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
import '../../shared/emoji/emoji_burst.dart';
|
||||
import '../../shared/me/me_sync.dart';
|
||||
import '../../shared/me/me_search_placement.dart';
|
||||
import 'me/me_assistant_picker.dart';
|
||||
import 'me/me_chat_assistant.dart';
|
||||
import 'me/me_messages_provider.dart';
|
||||
@@ -89,6 +90,7 @@ import 'reading_marks.dart';
|
||||
import 'timeline_message.dart';
|
||||
|
||||
part 'channel_detail_page/message_list.dart';
|
||||
part 'channel_detail_page/placement_route.dart';
|
||||
part 'channel_detail_page/empty_messages.dart';
|
||||
part 'channel_detail_page/oldest_navigation.dart';
|
||||
part 'channel_detail_page/system_rows.dart';
|
||||
@@ -107,14 +109,11 @@ part 'channel_detail_page/app_bar.dart';
|
||||
|
||||
/// Fetch deep-link targets that may be outside the loaded channel window.
|
||||
Future<void> _loadDeepLinkEvents(
|
||||
WidgetRef ref,
|
||||
String channelId,
|
||||
ChannelMessagesNotifier notifier,
|
||||
Set<String> eventIds,
|
||||
) async {
|
||||
try {
|
||||
await ref
|
||||
.read(channelMessagesProvider(channelId).notifier)
|
||||
.loadEventsById(eventIds);
|
||||
await notifier.loadEventsById(eventIds);
|
||||
} catch (error) {
|
||||
debugPrint('deep-link: failed to load target messages: $error');
|
||||
}
|
||||
@@ -253,11 +252,12 @@ enum InitialThreadRouteBehavior {
|
||||
replaceCurrentRoute,
|
||||
}
|
||||
|
||||
class ChannelDetailPage extends HookConsumerWidget {
|
||||
class _ResolvedChannelDetailPage extends HookConsumerWidget {
|
||||
final Channel channel;
|
||||
|
||||
/// Uses personal assistant routing for a chat placed in Me.
|
||||
final bool personalChat;
|
||||
final bool placementKnown;
|
||||
final String? initialAssistant;
|
||||
final String? initialMessageId;
|
||||
final String? initialThreadRootId;
|
||||
@@ -265,10 +265,11 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
/// How the automatically opened initial thread affects the route stack.
|
||||
final InitialThreadRouteBehavior initialThreadRouteBehavior;
|
||||
|
||||
const ChannelDetailPage({
|
||||
const _ResolvedChannelDetailPage({
|
||||
super.key,
|
||||
required this.channel,
|
||||
this.personalChat = false,
|
||||
required this.personalChat,
|
||||
required this.placementKnown,
|
||||
this.initialAssistant,
|
||||
this.initialMessageId,
|
||||
this.initialThreadRootId,
|
||||
@@ -561,7 +562,7 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
};
|
||||
if (eventIds.isEmpty) return null;
|
||||
final notifier = ref.read(messagesProvider.notifier);
|
||||
unawaited(_loadDeepLinkEvents(ref, channel.id, eventIds));
|
||||
unawaited(_loadDeepLinkEvents(notifier, eventIds));
|
||||
return () => notifier.releaseDeepLinkEvents(eventIds);
|
||||
},
|
||||
[
|
||||
@@ -813,8 +814,13 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
stickyDateHeaderState: stickyDateHeaderState,
|
||||
entries: timelineEntries,
|
||||
allMessages: timelineMessages,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
initialMessageId: personalChat
|
||||
? initialMessageId ?? initialThreadRootId
|
||||
: initialMessageId,
|
||||
initialThreadRootId:
|
||||
personalChat || !placementKnown
|
||||
? null
|
||||
: initialThreadRootId,
|
||||
initialThreadRouteBehavior:
|
||||
initialThreadRouteBehavior,
|
||||
initialOrdinaryUnreadMessageIds:
|
||||
@@ -912,13 +918,19 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {
|
||||
if (!placementKnown) {
|
||||
ref.invalidate(meGroupsProvider);
|
||||
throw StateError(
|
||||
'Reconnect to verify this chat before sending.',
|
||||
);
|
||||
}
|
||||
var recipients = mentionPubkeys;
|
||||
if (personalChat) {
|
||||
if (mePlacement?.asData?.value.channels
|
||||
.contains(channel.id) !=
|
||||
true) {
|
||||
throw StateError(
|
||||
'Refresh Me before sending to this chat.',
|
||||
'Refresh Solo before sending to this chat.',
|
||||
);
|
||||
}
|
||||
recipients = await meRecipients(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
/// Resolves Solo placement at the destination, including Activity/deep links.
|
||||
class ChannelDetailPage extends ConsumerWidget {
|
||||
final Channel channel;
|
||||
final bool personalChat;
|
||||
final String? initialAssistant;
|
||||
final String? initialMessageId;
|
||||
final String? initialThreadRootId;
|
||||
final InitialThreadRouteBehavior initialThreadRouteBehavior;
|
||||
|
||||
const ChannelDetailPage({
|
||||
super.key,
|
||||
required this.channel,
|
||||
this.personalChat = false,
|
||||
this.initialAssistant,
|
||||
this.initialMessageId,
|
||||
this.initialThreadRootId,
|
||||
this.initialThreadRouteBehavior = InitialThreadRouteBehavior.push,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final groups = ref.watch(meGroupsProvider).value;
|
||||
final fallback = ref.watch(meSearchPlacementProvider(channel.id));
|
||||
final placed = groups?.channels.contains(channel.id) ?? personalChat;
|
||||
// Bounded waiting keeps ordinary search results readable during an outage.
|
||||
// Unknown placement may be read, but cannot send via an unverified path.
|
||||
if (!placed && groups == null && fallback.isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(channel.name)),
|
||||
body: const Center(child: BuzzLoadingIndicator()),
|
||||
);
|
||||
}
|
||||
return _ResolvedChannelDetailPage(
|
||||
key: ValueKey((channel.id, placed)),
|
||||
channel: channel,
|
||||
personalChat: placed,
|
||||
placementKnown: groups != null || personalChat,
|
||||
initialAssistant: initialAssistant,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
initialThreadRouteBehavior: initialThreadRouteBehavior,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ class MePage extends HookConsumerWidget {
|
||||
return FrostedScaffold(
|
||||
backgroundColor: context.colors.surface,
|
||||
appBar: FrostedAppBar(
|
||||
nativeTitle: 'Me',
|
||||
nativeTitle: 'Solo',
|
||||
nativeLargeTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
nativeActions: [
|
||||
@@ -140,7 +140,7 @@ class MePage extends HookConsumerWidget {
|
||||
horizontalInset: Grid.gutter,
|
||||
showBottomDivider: true,
|
||||
bottomDividerOpacity: 0.07,
|
||||
title: Text('Me', style: headerTitleStyle),
|
||||
title: Text('Solo', style: headerTitleStyle),
|
||||
titleStyle: headerTitleStyle,
|
||||
actions: [
|
||||
ClipRRect(
|
||||
|
||||
@@ -132,7 +132,7 @@ Future<void> _offerMeRecovery(BuildContext context, MeSync sync) async {
|
||||
final discard = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Conflicting Me changes'),
|
||||
title: const Text('Conflicting Solo changes'),
|
||||
content: const Text(
|
||||
'Desktop and this phone changed the same project or chat. Keep the pending phone change to retry later, or discard it and use the current desktop organization. Your chat messages will be kept.',
|
||||
),
|
||||
|
||||
@@ -4,6 +4,39 @@ Future<(Channel, String)?> _showNewMeChat(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
final initialSync = ref.read(meSyncProvider);
|
||||
final unfinished = MeChatCreation.pending(initialSync);
|
||||
if (unfinished != null) {
|
||||
final resume = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Unfinished chat'),
|
||||
content: Text(
|
||||
'Resume “${unfinished.name}”, or discard its setup to start another chat. Any channel and messages already created are kept in Home or Solo.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Discard setup'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Resume'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (resume == null || !context.mounted) return null;
|
||||
if (!resume) {
|
||||
await unfinished.discard(initialSync);
|
||||
ref.invalidate(channelsProvider);
|
||||
}
|
||||
}
|
||||
if (!context.mounted) return null;
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
final sync = ref.read(meSyncProvider);
|
||||
final pending = MeChatCreation.pending(sync);
|
||||
|
||||
@@ -21,7 +21,7 @@ class _NewMeChat extends HookConsumerWidget {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(Grid.gutter),
|
||||
child: Text(
|
||||
'Your account or community changed. Return to Me to start a chat.',
|
||||
'Your account or community changed. Return to Solo to start a chat.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class HomePage extends HookConsumerWidget {
|
||||
_HomeDestination(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
selectedIcon: Icons.chat_bubble,
|
||||
label: 'Me',
|
||||
label: 'Solo',
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -56,6 +56,19 @@ class MeChatCreation {
|
||||
sync.check();
|
||||
}
|
||||
|
||||
/// Abandon only this reviewed intent, preserving any relay channel/messages.
|
||||
/// Partial channels remain accessible from Home (or Solo if already placed).
|
||||
Future<void> discard(MeSync sync) async {
|
||||
if (sync.disposed) throw StateError('Your community changed. Try again.');
|
||||
final current = pending(sync);
|
||||
if (current?.id != id || current?.name != name || current?.agent != agent) {
|
||||
throw StateError('The unfinished chat changed. Review it again.');
|
||||
}
|
||||
if (!await sync.prefs!.remove(storageKey(sync))) {
|
||||
throw StateError('Couldn’t discard this setup. Try again.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the journal through every relay step, including Me placement.
|
||||
Future<T> complete<T>(
|
||||
MeSync sync, {
|
||||
|
||||
@@ -18,7 +18,7 @@ class MeGroups {
|
||||
|
||||
factory MeGroups.decode(String plaintext, String community) {
|
||||
if (utf8.encode(plaintext).length > 16384) {
|
||||
throw const FormatException('Me storage is full');
|
||||
throw const FormatException('Solo storage is full');
|
||||
}
|
||||
final raw = jsonDecode(plaintext) as Map<String, dynamic>;
|
||||
final value = raw['value'] as Map<String, dynamic>;
|
||||
@@ -27,7 +27,7 @@ class MeGroups {
|
||||
raw['deleted'] is! bool ||
|
||||
value['type'] != 'groups' ||
|
||||
value['id'] != 'me') {
|
||||
throw const FormatException('Unsupported Me record');
|
||||
throw const FormatException('Unsupported Solo record');
|
||||
}
|
||||
final groups = (value['groups'] as List)
|
||||
.map((g) => Map<String, dynamic>.from(g as Map))
|
||||
@@ -59,7 +59,7 @@ class MeGroups {
|
||||
channels.length > 1000 ||
|
||||
channels.toSet().length != channels.length ||
|
||||
channels.any((id) => !channelId.hasMatch(id))) {
|
||||
throw const FormatException('Invalid Me projects');
|
||||
throw const FormatException('Invalid Solo projects');
|
||||
}
|
||||
if (raw['deleted'] == true) return const MeGroups();
|
||||
return MeGroups(
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'me_groups.dart';
|
||||
class MeRecoveryConflict implements Exception {
|
||||
@override
|
||||
String toString() =>
|
||||
'Me has conflicting edits on another device. Your pending change is still saved on this phone.';
|
||||
'Solo has conflicting edits on another device. Your pending change is still saved on this phone.';
|
||||
}
|
||||
|
||||
/// Reapply only local edits to a newer desktop record. Concurrent edits to the
|
||||
|
||||
@@ -12,13 +12,14 @@ import '../relay/relay.dart';
|
||||
import 'me_groups.dart';
|
||||
import 'me_recovery.dart';
|
||||
|
||||
/// A live, account/community-scoped reader for desktop's encrypted Me record.
|
||||
/// A live, account/community-scoped reader for desktop's encrypted Solo record.
|
||||
final meGroupsProvider = StreamProvider<MeGroups>((ref) {
|
||||
final service = ref.watch(meSyncProvider);
|
||||
final controller = StreamController<MeGroups>();
|
||||
var disposed = false;
|
||||
void Function()? unsubscribe;
|
||||
NostrEvent? latest;
|
||||
var hasSnapshot = false;
|
||||
void accept(NostrEvent event) {
|
||||
if (disposed ||
|
||||
(latest != null &&
|
||||
@@ -29,9 +30,11 @@ final meGroupsProvider = StreamProvider<MeGroups>((ref) {
|
||||
try {
|
||||
final value = service.decode(event);
|
||||
latest = event;
|
||||
hasSnapshot = true;
|
||||
controller.add(value);
|
||||
unawaited(service.cache(event).catchError((Object _) {}));
|
||||
} catch (error, stack) {
|
||||
controller.addError(error, stack);
|
||||
if (!hasSnapshot) controller.addError(error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,23 +45,35 @@ final meGroupsProvider = StreamProvider<MeGroups>((ref) {
|
||||
if (event != null) {
|
||||
accept(event);
|
||||
} else if (latest == null) {
|
||||
hasSnapshot = true;
|
||||
controller.add(const MeGroups());
|
||||
await service.cache(null);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
if (!disposed) controller.addError(error, stack);
|
||||
if (!disposed && !hasSnapshot) controller.addError(error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
try {
|
||||
final cached = service.cachedSnapshot();
|
||||
if (cached != null) {
|
||||
latest = cached.event;
|
||||
hasSnapshot = true;
|
||||
controller.add(cached.groups);
|
||||
}
|
||||
} catch (_) {
|
||||
// A corrupt cache must not prevent a fresh relay read.
|
||||
}
|
||||
try {
|
||||
service.check();
|
||||
unsubscribe = await service.session.subscribe(
|
||||
service.filter,
|
||||
accept,
|
||||
onClosed: (_) {
|
||||
if (!disposed) {
|
||||
if (!disposed && !hasSnapshot) {
|
||||
controller.addError(
|
||||
StateError('Couldn’t sync Me. Reconnect and try again.'),
|
||||
StateError('Couldn’t sync Solo. Reconnect and try again.'),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -69,7 +84,7 @@ final meGroupsProvider = StreamProvider<MeGroups>((ref) {
|
||||
}
|
||||
await refresh();
|
||||
} catch (error, stack) {
|
||||
if (!disposed) controller.addError(error, stack);
|
||||
if (!disposed && !hasSnapshot) controller.addError(error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +134,48 @@ class MeSync {
|
||||
|
||||
MeSync(this.session, this.config, {this.connected = true, this.prefs});
|
||||
|
||||
/// Encrypted last verified organization, isolated by account and community.
|
||||
String get cacheKey => 'me-cache:$community:$pubkey';
|
||||
|
||||
/// Read while offline. A signed event preserves replacement ordering.
|
||||
({NostrEvent? event, MeGroups groups})? cachedSnapshot() {
|
||||
if (disposed || config.nsec == null) return null;
|
||||
final saved = prefs?.getString(cacheKey);
|
||||
if (saved == null) return null;
|
||||
final value = jsonDecode(nip44Decrypt(key, saved)) as Map<String, dynamic>;
|
||||
if (value['community'] != community) {
|
||||
throw const FormatException('Invalid cached organization');
|
||||
}
|
||||
final json = value['event'] as Map<String, dynamic>?;
|
||||
final event = json == null ? null : NostrEvent.fromJson(json);
|
||||
return (
|
||||
event: event,
|
||||
groups: event == null ? const MeGroups() : decode(event),
|
||||
);
|
||||
}
|
||||
|
||||
/// Store only verified data; never overwrite a newer persisted head.
|
||||
Future<void> cache(NostrEvent? event) async {
|
||||
if (disposed || config.nsec == null || prefs == null) return;
|
||||
if (event != null) decode(event);
|
||||
try {
|
||||
final previous = cachedSnapshot()?.event;
|
||||
if (previous != null &&
|
||||
(event == null || !meEventIsNewer(event, previous))) {
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// Replace corrupt local storage with verified relay data.
|
||||
}
|
||||
await prefs!.setString(
|
||||
cacheKey,
|
||||
nip44Encrypt(
|
||||
key,
|
||||
jsonEncode({'community': community, 'event': event?.toJson()}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
NostrFilter get filter => NostrFilter(
|
||||
kinds: const [30078],
|
||||
authors: [pubkey],
|
||||
@@ -130,9 +187,9 @@ class MeSync {
|
||||
|
||||
void check() {
|
||||
if (!connected || config.nsec == null) {
|
||||
throw StateError('Connect to your community to load Me.');
|
||||
throw StateError('Connect to your community to load Solo.');
|
||||
}
|
||||
if (disposed) throw StateError('Your community changed. Open Me again.');
|
||||
if (disposed) throw StateError('Your community changed. Open Solo again.');
|
||||
}
|
||||
|
||||
MeGroups decode(NostrEvent event) {
|
||||
@@ -148,7 +205,7 @@ class MeSync {
|
||||
!event.tags.any(
|
||||
(t) => t.length == 2 && t[0] == 't' && t[1] == MeGroups.tag,
|
||||
)) {
|
||||
throw const FormatException('Invalid Me record');
|
||||
throw const FormatException('Invalid Solo record');
|
||||
}
|
||||
nostr.Event.fromJson(jsonEncode(event.toJson()));
|
||||
return MeGroups.decode(nip44Decrypt(key, event.content), community);
|
||||
@@ -178,7 +235,7 @@ class MeSync {
|
||||
/// Refresh before applying an intent, then confirm the selected signed event.
|
||||
Future<void> update(MeGroups Function(MeGroups) change) async {
|
||||
check();
|
||||
if (_saving) throw StateError('A Me change is still being saved.');
|
||||
if (_saving) throw StateError('A Solo change is still being saved.');
|
||||
_saving = true;
|
||||
try {
|
||||
MeGroups? recovered;
|
||||
@@ -213,20 +270,20 @@ class MeSync {
|
||||
await session.publish(event);
|
||||
if ((await head())?.id != event.id) {
|
||||
throw StateError(
|
||||
'A previous Me save is still unconfirmed. Try again.',
|
||||
'A previous Solo save is still unconfirmed. Try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
await prefs?.remove(pendingKey);
|
||||
throw StateError(
|
||||
'Your previous Me save is confirmed. Review your projects before making another change.',
|
||||
'Your previous Solo save is confirmed. Review your projects before making another change.',
|
||||
);
|
||||
}
|
||||
}
|
||||
final before = await head();
|
||||
if (recovered != null && before?.id != recoveredHead) {
|
||||
throw StateError(
|
||||
'Me changed again. Your pending change is saved; try again.',
|
||||
'Solo changed again. Your pending change is saved; try again.',
|
||||
);
|
||||
}
|
||||
final value =
|
||||
@@ -243,7 +300,7 @@ class MeSync {
|
||||
final current = await head();
|
||||
if (before?.id != current?.id) {
|
||||
throw StateError(
|
||||
'Me changed on another device. Refresh and try again.',
|
||||
'Solo changed on another device. Refresh and try again.',
|
||||
);
|
||||
}
|
||||
if (current != null &&
|
||||
@@ -275,13 +332,13 @@ class MeSync {
|
||||
check();
|
||||
if ((await head())?.id != signed.id) {
|
||||
throw StateError(
|
||||
'Another Me change is selected. Refresh to see the latest projects.',
|
||||
'Another Solo change is selected. Refresh to see the latest projects.',
|
||||
);
|
||||
}
|
||||
await prefs?.remove(pendingKey);
|
||||
if (recovered != null) {
|
||||
throw StateError(
|
||||
'Your previous Me save was recovered alongside the desktop changes. Review your projects before making another change.',
|
||||
'Your previous Solo save was recovered alongside the desktop changes. Review your projects before making another change.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:buzz/features/channels/compose_bar.dart';
|
||||
import 'package:buzz/features/channels/agent_reply_handoff.dart';
|
||||
import 'package:buzz/features/channels/me/me_messages_provider.dart';
|
||||
import 'package:buzz/shared/me/me_assistants.dart';
|
||||
@@ -281,6 +282,7 @@ Widget _buildTestable({
|
||||
http.Client? mediaClient,
|
||||
VideoPreviewFrameLoader? videoPreviewLoader,
|
||||
Widget? home,
|
||||
Stream<MeGroups>? organization,
|
||||
}) {
|
||||
final resolvedChannel = channel ?? _testChannel;
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
@@ -291,6 +293,13 @@ Widget _buildTestable({
|
||||
return ProviderScope(
|
||||
retry: providerRetry ?? (disableRetries ? (_, _) => null : null),
|
||||
overrides: [
|
||||
meGroupsProvider.overrideWith(
|
||||
(ref) =>
|
||||
organization ??
|
||||
Stream.value(
|
||||
MeGroups(channels: personalChat ? [resolvedChannel.id] : []),
|
||||
),
|
||||
),
|
||||
if (personalChat) ...[
|
||||
meSyncProvider.overrideWithValue(
|
||||
MeSync(
|
||||
@@ -307,7 +316,6 @@ Widget _buildTestable({
|
||||
),
|
||||
meMessagesProvider(_channelId).overrideWith(() => fakeMessagesNotifier),
|
||||
meAssistantsProvider.overrideWith((ref) async => []),
|
||||
meGroupsProvider.overrideWith((ref) => Stream.value(const MeGroups())),
|
||||
],
|
||||
if (videoPreviewLoader != null)
|
||||
videoPreviewFrameLoaderProvider.overrideWithValue(videoPreviewLoader),
|
||||
@@ -10233,6 +10241,65 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('unknown Solo placement remains readable but cannot send', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
disableRetries: true,
|
||||
organization: Stream.error(StateError('offline')),
|
||||
messages: [
|
||||
_textMsg(id: 'visible', pubkey: 'alice', content: 'Cached message'),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(findRichText('Cached message'), findsOneWidget);
|
||||
final compose = tester.widget<ComposeBar>(find.byType(ComposeBar));
|
||||
await expectLater(
|
||||
compose.onSend('Do not route without placement', []),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'default Activity/deep-link entry resolves Solo and keeps replies inline',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
personalChat: true,
|
||||
messages: [
|
||||
_textMsg(id: threadIdRoot, pubkey: 'self', content: 'Question'),
|
||||
_textMsg(
|
||||
id: threadIdReplyZero,
|
||||
pubkey: 'alice',
|
||||
content: 'Agent answer',
|
||||
extraTags: [
|
||||
['e', threadIdRoot, '', 'reply'],
|
||||
],
|
||||
),
|
||||
],
|
||||
home: ChannelDetailPage(
|
||||
channel: _testChannel,
|
||||
initialMessageId: threadIdReplyZero,
|
||||
initialThreadRootId: threadIdRoot,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('me-assistant-button')), findsOneWidget);
|
||||
expect(findRichText('Agent answer'), findsOneWidget);
|
||||
expect(find.byType(ThreadDetailPage), findsNothing);
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(ChannelDetailPage)),
|
||||
);
|
||||
final notifier =
|
||||
container.read(meMessagesProvider(_channelId).notifier)
|
||||
as _FakeMessagesNotifier;
|
||||
expect(notifier.loadedEventIds, contains(threadIdReplyZero));
|
||||
},
|
||||
);
|
||||
|
||||
group('Typing indicator', () {
|
||||
testWidgets('Me reply consumes typing and grows its pending bubble', (
|
||||
tester,
|
||||
@@ -16257,6 +16324,13 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier {
|
||||
final List<List<NostrEvent>> _olderPages;
|
||||
final bool failOlderFetch;
|
||||
final Map<String, ChannelWindowThreadSummary> summaries;
|
||||
final loadedEventIds = <String>{};
|
||||
@override
|
||||
Future<void> loadEventsById(Iterable<String> eventIds) async {
|
||||
loadedEventIds.addAll(eventIds);
|
||||
await super.loadEventsById(eventIds);
|
||||
}
|
||||
|
||||
int fetchOlderCalls = 0;
|
||||
|
||||
_FakeMessagesNotifier(
|
||||
|
||||
@@ -227,85 +227,97 @@ void main() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('iOS new chat restores a durable creation after restart', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
const channel = MethodChannel('buzz/profile_text_editor');
|
||||
final calls = <MethodCall>[];
|
||||
var directoryLoads = 0;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
|
||||
call,
|
||||
for (final resume in [true, false]) {
|
||||
testWidgets('iOS new chat recovers a durable creation: resume=$resume', (
|
||||
tester,
|
||||
) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
});
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
null,
|
||||
),
|
||||
);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final sync = MeSync(
|
||||
RelaySessionNotifier(),
|
||||
RelayConfig(
|
||||
baseUrl: 'https://relay.test',
|
||||
nsec: nostr.Nip19.encode(
|
||||
prefix: nostr.Nip19Prefix.nsec,
|
||||
data:
|
||||
'0000000000000000000000000000000000000000000000000000000000000001',
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
const channel = MethodChannel('buzz/profile_text_editor');
|
||||
final calls = <MethodCall>[];
|
||||
var directoryLoads = 0;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
|
||||
call,
|
||||
) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
});
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
null,
|
||||
),
|
||||
),
|
||||
prefs: prefs,
|
||||
);
|
||||
await const MeChatCreation(
|
||||
'same-id',
|
||||
'Unfinished chat',
|
||||
'agent',
|
||||
).save(sync);
|
||||
final restarted = MeSync(RelaySessionNotifier(), sync.config, prefs: prefs);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
meGroupsProvider.overrideWith(
|
||||
(ref) => Stream.value(const MeGroups()),
|
||||
);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final sync = MeSync(
|
||||
RelaySessionNotifier(),
|
||||
RelayConfig(
|
||||
baseUrl: 'https://relay.test',
|
||||
nsec: nostr.Nip19.encode(
|
||||
prefix: nostr.Nip19Prefix.nsec,
|
||||
data:
|
||||
'0000000000000000000000000000000000000000000000000000000000000001',
|
||||
),
|
||||
meSyncProvider.overrideWithValue(restarted),
|
||||
channelsProvider.overrideWith(FakeChannels.new),
|
||||
meAssistantsProvider.overrideWith((ref) async {
|
||||
directoryLoads++;
|
||||
return [];
|
||||
}),
|
||||
],
|
||||
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
),
|
||||
prefs: prefs,
|
||||
);
|
||||
await const MeChatCreation(
|
||||
'same-id',
|
||||
'Unfinished chat',
|
||||
'agent',
|
||||
).save(sync);
|
||||
final restarted = MeSync(
|
||||
RelaySessionNotifier(),
|
||||
sync.config,
|
||||
prefs: prefs,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
meGroupsProvider.overrideWith(
|
||||
(ref) => Stream.value(const MeGroups()),
|
||||
),
|
||||
meSyncProvider.overrideWithValue(restarted),
|
||||
channelsProvider.overrideWith(FakeChannels.new),
|
||||
meAssistantsProvider.overrideWith((ref) async {
|
||||
directoryLoads++;
|
||||
return [];
|
||||
}),
|
||||
],
|
||||
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final bar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
|
||||
bar.nativeActions!
|
||||
.firstWhere((action) => action.label == 'New chat')
|
||||
.onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
expect(directoryLoads, 1);
|
||||
expect(calls, hasLength(1));
|
||||
expect(calls.single.method, 'presentChat');
|
||||
final args = calls.single.arguments as Map;
|
||||
expect(args['title'], 'New chat');
|
||||
expect(args['initialValue'], 'Unfinished chat');
|
||||
expect(args['selectedAssistant'], 'agent');
|
||||
expect(args['fieldsLocked'], isTrue);
|
||||
expect(args['error'], contains('Resume'));
|
||||
expect(args['placeholder'], 'Chat name');
|
||||
expect(args['assistants'], isEmpty);
|
||||
expect(args['containerCornerRadius'], Radii.container);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
final bar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
|
||||
bar.nativeActions!
|
||||
.firstWhere((action) => action.label == 'New chat')
|
||||
.onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(resume ? 'Resume' : 'Discard setup'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(directoryLoads, 1);
|
||||
expect(calls, hasLength(1));
|
||||
expect(calls.single.method, 'presentChat');
|
||||
final args = calls.single.arguments as Map;
|
||||
expect(args['title'], 'New chat');
|
||||
expect(args['initialValue'], resume ? 'Unfinished chat' : '');
|
||||
expect(args['selectedAssistant'], resume ? 'agent' : isNull);
|
||||
expect(args['fieldsLocked'], resume);
|
||||
expect(args['error'], resume ? contains('Resume') : isNull);
|
||||
expect(
|
||||
MeChatCreation.pending(restarted)?.id,
|
||||
resume ? 'same-id' : isNull,
|
||||
);
|
||||
expect(args['placeholder'], 'Chat name');
|
||||
expect(args['assistants'], isEmpty);
|
||||
expect(args['containerCornerRadius'], Radii.container);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('unavailable preferences show retry instead of empty state', (
|
||||
tester,
|
||||
|
||||
@@ -132,7 +132,7 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.bySemanticsLabel('Activity, unread'), findsNothing);
|
||||
await tester.tap(find.byTooltip('Me'));
|
||||
await tester.tap(find.byTooltip('Solo'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Personal workspace'), findsOneWidget);
|
||||
expect(find.byType(SearchPage), findsNothing);
|
||||
@@ -336,7 +336,7 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(1));
|
||||
|
||||
await tester.tap(find.byTooltip('Me'));
|
||||
await tester.tap(find.byTooltip('Solo'));
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(2));
|
||||
});
|
||||
|
||||
@@ -35,6 +35,38 @@ void main() {
|
||||
);
|
||||
expect(touchedRelay, isFalse);
|
||||
});
|
||||
test(
|
||||
'discard preserves partial relay state and permits a different intent offline',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final config = RelayConfig(
|
||||
baseUrl: 'https://relay.test',
|
||||
nsec: nostr.Nip19.encode(
|
||||
prefix: nostr.Nip19Prefix.nsec,
|
||||
data: '0' * 63 + '1',
|
||||
),
|
||||
);
|
||||
final online = MeSync(RelaySessionNotifier(), config, prefs: prefs);
|
||||
const intent = MeChatCreation('old', 'Old', 'revoked-agent');
|
||||
await intent.save(online);
|
||||
final offline = MeSync(
|
||||
RelaySessionNotifier(),
|
||||
config,
|
||||
prefs: prefs,
|
||||
connected: false,
|
||||
);
|
||||
await expectLater(
|
||||
const MeChatCreation('wrong', 'Old', 'revoked-agent').discard(offline),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(MeChatCreation.pending(offline)?.id, 'old');
|
||||
await intent.discard(offline);
|
||||
expect(MeChatCreation.pending(offline), isNull);
|
||||
await const MeChatCreation('new', 'New', 'other-agent').save(online);
|
||||
expect(MeChatCreation.pending(online)?.id, 'new');
|
||||
},
|
||||
);
|
||||
for (final crashAfter in [0, 1, 2, 3]) {
|
||||
test('restart resumes the same chat after boundary $crashAfter', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
@@ -11,6 +12,18 @@ import 'package:buzz/shared/relay/relay.dart';
|
||||
|
||||
class FakeRelay extends RelaySessionNotifier {
|
||||
List<NostrEvent> events = [];
|
||||
Future<List<NostrEvent>>? pendingRead;
|
||||
void Function(String)? closeSubscription;
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
closeSubscription = onClosed;
|
||||
return () {};
|
||||
}
|
||||
|
||||
int writes = 0;
|
||||
bool loseAcknowledgment = false;
|
||||
void Function()? beforeRead;
|
||||
@@ -20,7 +33,7 @@ class FakeRelay extends RelaySessionNotifier {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
beforeRead?.call();
|
||||
return events;
|
||||
return pendingRead ?? events;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -62,6 +75,149 @@ void main() {
|
||||
verify: false,
|
||||
).toMap(),
|
||||
);
|
||||
test(
|
||||
'offline relaunch emits encrypted cached organization and isolates accounts',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final config = RelayConfig(baseUrl: origin, nsec: nsec);
|
||||
final online = MeSync(FakeRelay(), config, prefs: prefs);
|
||||
const groups = MeGroups(
|
||||
groups: [
|
||||
{'id': 'project', 'name': 'Private project', 'defaultTemplateId': ''},
|
||||
],
|
||||
);
|
||||
await online.cache(signed(groups: groups, timestamp: 3));
|
||||
await online.cache(signed(timestamp: 2));
|
||||
await online.cache(null);
|
||||
expect(
|
||||
prefs.getString(online.cacheKey),
|
||||
isNot(contains('Private project')),
|
||||
);
|
||||
final offline = MeSync(
|
||||
FakeRelay(),
|
||||
config,
|
||||
prefs: prefs,
|
||||
connected: false,
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [meSyncProvider.overrideWithValue(offline)],
|
||||
retry: (_, _) => null,
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
container.listen(meGroupsProvider, (_, _) {});
|
||||
expect(
|
||||
(await container.read(meGroupsProvider.future)).groups.single['name'],
|
||||
'Private project',
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(container.read(meGroupsProvider).hasError, isFalse);
|
||||
for (final other in [
|
||||
RelayConfig(baseUrl: 'https://other.test', nsec: nsec),
|
||||
RelayConfig(
|
||||
baseUrl: origin,
|
||||
nsec: nostr.Nip19.encode(
|
||||
prefix: nostr.Nip19Prefix.nsec,
|
||||
data: '0' * 63 + '2',
|
||||
),
|
||||
),
|
||||
]) {
|
||||
expect(
|
||||
MeSync(
|
||||
FakeRelay(),
|
||||
other,
|
||||
prefs: prefs,
|
||||
connected: false,
|
||||
).cachedSnapshot(),
|
||||
isNull,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
test(
|
||||
'corrupt cache refreshes online and persists the verified replacement',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final relay = FakeRelay()..events = [signed(timestamp: 5)];
|
||||
final service = MeSync(
|
||||
relay,
|
||||
RelayConfig(baseUrl: origin, nsec: nsec),
|
||||
prefs: prefs,
|
||||
);
|
||||
await prefs.setString(service.cacheKey, 'corrupt');
|
||||
final container = ProviderContainer(
|
||||
overrides: [meSyncProvider.overrideWithValue(service)],
|
||||
retry: (_, _) => null,
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
container.listen(meGroupsProvider, (_, _) {});
|
||||
expect((await container.read(meGroupsProvider.future)).channels, isEmpty);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(service.cachedSnapshot()?.event?.id, relay.events.single.id);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'empty organization survives relaunch without relay connectivity',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final config = RelayConfig(baseUrl: origin, nsec: nsec);
|
||||
await MeSync(FakeRelay(), config, prefs: prefs).cache(null);
|
||||
final offline = MeSync(
|
||||
FakeRelay(),
|
||||
config,
|
||||
prefs: prefs,
|
||||
connected: false,
|
||||
);
|
||||
expect(offline.cachedSnapshot()?.groups.channels, isEmpty);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'cached organization emits before refresh and survives a closed subscription',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final response = Completer<List<NostrEvent>>();
|
||||
final relay = FakeRelay()..pendingRead = response.future;
|
||||
final service = MeSync(
|
||||
relay,
|
||||
RelayConfig(baseUrl: origin, nsec: nsec),
|
||||
prefs: prefs,
|
||||
);
|
||||
final old = signed(timestamp: 1);
|
||||
await service.cache(old);
|
||||
final container = ProviderContainer(
|
||||
overrides: [meSyncProvider.overrideWithValue(service)],
|
||||
retry: (_, _) => null,
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
container.listen(meGroupsProvider, (_, _) {});
|
||||
expect((await container.read(meGroupsProvider.future)).groups, isEmpty);
|
||||
final fresh = signed(
|
||||
timestamp: 2,
|
||||
groups: const MeGroups(
|
||||
groups: [
|
||||
{'id': 'new', 'name': 'From desktop', 'defaultTemplateId': ''},
|
||||
],
|
||||
),
|
||||
);
|
||||
response.complete([fresh]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(
|
||||
container.read(meGroupsProvider).value?.groups.single['name'],
|
||||
'From desktop',
|
||||
);
|
||||
expect(service.cachedSnapshot()?.event?.id, fresh.id);
|
||||
relay.closeSubscription!('offline');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(container.read(meGroupsProvider).hasError, isFalse);
|
||||
expect(
|
||||
container.read(meGroupsProvider).value?.groups.single['name'],
|
||||
'From desktop',
|
||||
);
|
||||
},
|
||||
);
|
||||
test('verifies signature, account and encrypted community envelope', () {
|
||||
final sync = MeSync(FakeRelay(), RelayConfig(baseUrl: origin, nsec: nsec));
|
||||
expect(sync.decode(signed()).channels, isEmpty);
|
||||
|
||||
Reference in New Issue
Block a user