From c9e35f8ac71f4f2eb7a8e256d0bc83f3518031af Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 20 Oct 2026 11:10:24 -0400 Subject: [PATCH] fix(mobile): restore Solo chats from channel labels (#8466) Restore Solo chats and project placement on mobile using `buzz.solo` labels and creator metadata. Invalidate stale channel details when identity changes so opening and sending preserve refreshed Solo identity; shared chats retain ordinary sending. New mobile Solo chats publish the discovery label, and the personal send guard enforces ownership even when route hints or legacy placement suggest Solo. The current desktop producer lives in `block/buzz-app`. [Contract documentation](https://github.com/block/buzz/blob/0201ca0572536f703705a5ba9cc0947fed5b27e8/docs/solo-mobile-contract.md) links its production creation call to relay identity generation and mobile regression coverage. Public metadata fields are documented. Validation: clean analysis; 3,542 mobile tests (5 skipped), 4 push checks, and 280 core unit/doc tests passed. Removing the cache fix reproduces the send failure. Local push hooks were skipped with owner approval after an unrelated terminal timeout and disk exhaustion; full local CI is not green. GitHub CI must validate the pushed commit. The initial build was installed/launched on iPhone; follow-up native and live cross-client confirmation remain pending. The ownership regression supplies a valid owned member recipient and checks the exact rejection. A New chat UI test captures the real signed kind-9007 command. Both regressions were mutation-verified: removing the ownership guard or the production creation label causes the corresponding test to fail. --------- Signed-off-by: kenny lopez Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- crates/buzz-core/src/channel.rs | 31 ++ docs/solo-mobile-contract.md | 42 +++ mobile/lib/features/channels/channel.dart | 21 ++ .../channels/channel_detail_page.dart | 8 +- .../channel_detail_page/placement_route.dart | 5 +- .../channels/channel_management_actions.dart | 2 + .../channels/channel_management_provider.dart | 5 +- .../lib/features/channels/channels_page.dart | 3 +- .../features/channels/channels_provider.dart | 23 +- mobile/lib/features/channels/me/me_page.dart | 12 +- .../channels/me/me_unread_provider.dart | 2 +- .../channels/me/native_new_me_chat.dart | 1 + mobile/lib/shared/relay/nostr_models.dart | 14 + .../channels/channel_detail_page_test.dart | 277 +++++++++++++++++- .../channel_management_provider_test.dart | 69 +++++ .../features/channels/me/me_page_test.dart | 58 ++++ .../channels/me/solo_creation_tags_test.dart | 146 +++++++++ scripts/contracts/capture-solo-creation.mjs | 67 +++++ test-fixtures/solo-creation.json | 21 ++ 19 files changed, 776 insertions(+), 31 deletions(-) create mode 100644 docs/solo-mobile-contract.md create mode 100644 mobile/test/features/channels/me/solo_creation_tags_test.dart create mode 100644 scripts/contracts/capture-solo-creation.mjs create mode 100644 test-fixtures/solo-creation.json diff --git a/crates/buzz-core/src/channel.rs b/crates/buzz-core/src/channel.rs index a74ae257ae..bf8c3706fb 100644 --- a/crates/buzz-core/src/channel.rs +++ b/crates/buzz-core/src/channel.rs @@ -368,6 +368,37 @@ mod tests { } } + #[test] + fn desktop_solo_creation_preserves_mobile_identity_contract() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../../../test-fixtures/solo-creation.json")) + .expect("Solo creation fixture"); + let tags: Vec> = serde_json::from_value(fixture["creation"]["tags"].clone()) + .expect("captured production command tags"); + let labels = super::parse_channel_labels( + tags.iter() + .filter(|tag| tag[0] == "t") + .map(|tag| tag[1].as_str()), + ) + .expect("valid creation labels"); + let channel_type = tags + .iter() + .find(|tag| tag[0] == "channel_type") + .expect("creation type")[1] + .as_str(); + let creator = + hex::decode(fixture["creator"].as_str().expect("creator")).expect("creator hex"); + let actual: Vec> = group_state_identity_tags(channel_type, &labels, &creator) + .expect("relay identity tags") + .into_iter() + .map(|tag| tag.to_vec()) + .collect(); + assert_eq!( + serde_json::to_value(actual).expect("identity JSON"), + fixture["identity_tags"] + ); + } + #[test] fn group_state_identity_tags_put_the_type_first_then_labels_then_the_creator() { let creator = [0xab; 32]; diff --git a/docs/solo-mobile-contract.md b/docs/solo-mobile-contract.md new file mode 100644 index 0000000000..e7b17ea5f4 --- /dev/null +++ b/docs/solo-mobile-contract.md @@ -0,0 +1,42 @@ +# Solo desktop/mobile contract + +The current Solo producer is **[block/buzz-app](https://github.com/block/buzz-app)**, +not the older `desktop/` client retained in this repository. + +The captured producer revision is +[`64726576ed3b082daa5786e5e7ca6b5a018663a1`](https://github.com/block/buzz-app/commit/64726576ed3b082daa5786e5e7ca6b5a018663a1): + +- [`SoloDraft.tsx`](https://github.com/block/buzz-app/blob/64726576ed3b082daa5786e5e7ca6b5a018663a1/src/features/solo/SoloDraft.tsx) calls `session.channelWrites.create`. +- [`channel-writes.ts`](https://github.com/block/buzz-app/blob/64726576ed3b082daa5786e5e7ca6b5a018663a1/src/features/relay/channel-writes.ts) emits kind 9007 with `channel_type=stream`, `visibility=private`, and `t=buzz.solo`. Its existing production-service test is “creates a Solo chat in its single durable outbox command” in `channel-writes.test.ts`. +- [`model.ts`](https://github.com/block/buzz-app/blob/64726576ed3b082daa5786e5e7ca6b5a018663a1/src/features/channel-templates/model.ts) retains project assignments but drops the legacy `groups.channels` discovery list. + +The desktop does **not** author `P`. The relay derives the creator from the +signed creation event. In this repository, `channel_labels_from_tags` in +`crates/buzz-relay/src/handlers/side_effects.rs` parses creation labels; +`group_state_identity_tags` in `crates/buzz-core/src/channel.rs` emits the +leading type `t`, subsequent label `t` tags, and uppercase creator `P`. +`emit_group_discovery_events` uses that builder for relay metadata. Mobile reads those +facts through `ChannelData.fromEvent` and its channel providers. + +## Reproducible contract coverage + +`test-fixtures/solo-creation.json` contains an **actual captured production +create command**, plus expected relay identity tags using a synthetic creator. +To recapture it from a checkout of the pinned desktop revision: + +```sh +node --experimental-transform-types scripts/contracts/capture-solo-creation.mjs /path/to/buzz-app > test-fixtures/solo-creation.json +``` + +The script invokes `createChannelWrites(...).create(...)` and captures its +outbox command without publishing anything. The core test +`desktop_solo_creation_preserves_mobile_identity_contract` feeds those command +labels through the production parser and relay identity builder. The mobile +regression “refresh replaces stale Solo details before opening and sending” +consumes the same identity tags through the real list/detail providers, primes +old details, refreshes, opens Solo, and sends through the personal sender with +an empty legacy chat list. Existing widget coverage checks project and unfiled +placement and shared-chat ordinary sending. + +These are compositional contract tests, not a live desktop/relay/device E2E +run. Physical-device installation and launch alone do not prove that journey. diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 924f9245c1..40813895ed 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -11,6 +11,9 @@ const privateChannelAddDeniedMessage = class Channel { final String id; final String name; + + /// NIP-CL labels from metadata; presentation hints, never access grants. + final List labels; final String channelType; // "stream", "forum", "dm" final String visibility; // "open", "private" final String description; @@ -31,6 +34,7 @@ class Channel { required this.id, required this.name, required this.channelType, + this.labels = const [], required this.visibility, required this.description, required this.createdBy, @@ -51,6 +55,7 @@ class Channel { id: json['id'] as String, name: json['name'] as String, channelType: json['channel_type'] as String, + labels: (json['labels'] as List? ?? const []).cast(), visibility: json['visibility'] as String, description: (json['description'] as String?) ?? '', topic: json['topic'] as String?, @@ -78,6 +83,14 @@ class Channel { bool get isEphemeral => ttlSeconds != null || ttlDeadline != null; + /// Solo presentation is carried by NIP-CL metadata, not project filing. + bool get isSolo => isStream && isPrivate && labels.contains('buzz.solo'); + + /// Keep old mobile-created chats visible while older clients migrate. + bool isPersonalChat(String? viewer, Iterable legacyChannels) => isSolo + ? viewer != null && createdBy == viewer + : legacyChannels.contains(id); + bool get isStream => channelType == 'stream'; bool get isForum => channelType == 'forum'; @@ -131,6 +144,7 @@ class Channel { id: id, name: details.name, channelType: details.channelType, + labels: details.labels, visibility: details.visibility, description: details.description, topic: details.topic, @@ -158,6 +172,7 @@ class Channel { id: id, name: name ?? this.name, channelType: channelType, + labels: labels, visibility: visibility, description: description ?? this.description, topic: topic, @@ -183,6 +198,9 @@ class Channel { class ChannelDetails { final String id; final String name; + + /// NIP-CL labels from metadata; presentation hints, never access grants. + final List labels; final String channelType; final String visibility; final String description; @@ -199,6 +217,7 @@ class ChannelDetails { required this.id, required this.name, required this.channelType, + this.labels = const [], required this.visibility, required this.description, required this.createdBy, @@ -215,6 +234,7 @@ class ChannelDetails { id: json['id'] as String, name: json['name'] as String, channelType: json['channel_type'] as String, + labels: (json['labels'] as List? ?? const []).cast(), visibility: json['visibility'] as String, description: (json['description'] as String?) ?? '', topic: json['topic'] as String?, @@ -235,6 +255,7 @@ class ChannelDetails { id: channel.id, name: channel.name, channelType: channel.channelType, + labels: channel.labels, visibility: channel.visibility, description: channel.description, topic: channel.topic, diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index be320dc0c5..64f4b52948 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -936,9 +936,11 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { } var recipients = mentionPubkeys; if (personalChat) { - if (mePlacement?.asData?.value.channels - .contains(channel.id) != - true) { + if (!resolvedChannel.isPersonalChat( + ref.read(myPubkeyProvider), + mePlacement?.asData?.value.channels ?? + const [], + )) { throw StateError( 'Refresh Solo before sending to this chat.', ); diff --git a/mobile/lib/features/channels/channel_detail_page/placement_route.dart b/mobile/lib/features/channels/channel_detail_page/placement_route.dart index 26c0cdcd82..66f74a1721 100644 --- a/mobile/lib/features/channels/channel_detail_page/placement_route.dart +++ b/mobile/lib/features/channels/channel_detail_page/placement_route.dart @@ -29,7 +29,10 @@ class ChannelDetailPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final groups = ref.watch(meGroupsProvider).value; + final details = ref.watch(channelDetailsProvider(channel.id)).value; + final current = details == null ? channel : channel.mergeDetails(details); final placed = + current.isPersonalChat(ref.watch(myPubkeyProvider), const []) || personalChat || placement == ChannelRoutePlacement.solo || (groups?.channels.contains(channel.id) ?? false); @@ -45,7 +48,7 @@ class ChannelDetailPage extends ConsumerWidget { } return _ResolvedChannelDetailPage( key: ValueKey((channel.id, placed)), - channel: channel, + channel: current, personalChat: placed, placementKnown: placed || diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index 46e161d2af..2608ee97bb 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -38,6 +38,7 @@ class ChannelActions { required String name, required String channelType, required String visibility, + List labels = const [], String? description, int? ttlSeconds, }) async { @@ -47,6 +48,7 @@ class ChannelActions { name: name, channelType: channelType, visibility: visibility, + labels: labels, description: description, ttlSeconds: ttlSeconds, ); diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 1eb8e25776..70db7df670 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -490,10 +490,11 @@ ChannelDetails channelDetailsFromEvent(NostrEvent event) { id: data.id, name: data.name, channelType: data.channelType, + labels: data.labels, visibility: data.visibility, description: data.description, topic: data.topic, - createdBy: event.pubkey, + createdBy: data.creator ?? event.pubkey, createdAt: eventTime, memberCount: 0, // Same archival-timestamp convention as `_channelFromMeta` — the event's @@ -668,6 +669,7 @@ List> buildCreateChannelTags({ required String name, required String channelType, required String visibility, + List labels = const [], String? description, int? ttlSeconds, }) { @@ -680,6 +682,7 @@ List> buildCreateChannelTags({ ['name', name], ['visibility', visibility], ['channel_type', channelType], + for (final label in labels) ['t', label], if (description case final about? when about.trim().isNotEmpty) ['about', about.trim()], if (ttlSeconds != null) ['ttl', ttlSeconds.toString()], diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 08c5376c63..b0b6b1f368 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -232,10 +232,11 @@ class ChannelsPage extends HookConsumerWidget { if (channelsAsync.asData?.value case final data?) { cachedChannels.value = data; } + final soloViewer = ref.watch(myPubkeyProvider); final personalIds = ref.watch(meGroupsProvider).asData?.value.channels ?? const []; final channels = cachedChannels.value - ?.where((channel) => !personalIds.contains(channel.id)) + ?.where((channel) => !channel.isPersonalChat(soloViewer, personalIds)) .toList(); Future openChannel(Channel channel) async { if (!context.mounted) return; diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index a7ac5dad44..1119a9a8f9 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; @@ -430,16 +431,8 @@ class ChannelsNotifier extends AsyncNotifier> { return left.name.toLowerCase().compareTo(right.name.toLowerCase()); }); - // Invalidate `channelDetailsProvider` entries whose archived state flipped - // since the last fetch. Required because `channelDetailsProvider` is a - // separate Riverpod cache and `Channel.mergeDetails(details)` overwrites - // archivedAt from the cached details — so an active-then-archived channel - // (e.g. TTL auto-archive by the relay reaper) could keep showing compose - // and manage actions in the detail view until the cache expired naturally. - // - // Scoped narrowly to the archived flip — broader metadata staleness - // (renames, topic changes, etc.) is a separate, pre-existing concern that - // already affects this provider for other reasons. + // Details are a separate cache. Invalidate changes that would otherwise + // overwrite fresh Solo identity or archive state when opening a channel. // Re-check before the first write that other providers can observe. Every // await above is fenced, but the switch can also land in the synchronous // gap, so the guard sits immediately before the write rather than only @@ -451,7 +444,12 @@ class ChannelsNotifier extends AsyncNotifier> { }; for (final channel in channels) { final prev = prevById[channel.id]; - if (prev != null && prev.isArchived != channel.isArchived) { + if (prev == null || + prev.isArchived != channel.isArchived || + prev.channelType != channel.channelType || + prev.visibility != channel.visibility || + prev.createdBy != channel.createdBy || + !listEquals(prev.labels, channel.labels)) { ref.invalidate(channelDetailsProvider(channel.id)); } } @@ -623,10 +621,11 @@ class ChannelsNotifier extends AsyncNotifier> { id: data.id, name: data.name, channelType: data.channelType, + labels: data.labels, visibility: data.visibility, description: data.description, topic: data.topic, - createdBy: event.pubkey, + createdBy: data.creator ?? event.pubkey, createdAt: DateTime.fromMillisecondsSinceEpoch( event.createdAt * 1000, isUtc: true, diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 19df5b9126..434c0d3ace 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -18,6 +18,7 @@ import '../../../shared/me/me_groups.dart'; import '../../../shared/me/me_chat_creation.dart'; import '../../../shared/me/me_project_creation.dart'; import '../../../shared/me/me_sync.dart'; +import '../../../shared/relay/relay.dart'; import '../../../shared/me/me_recovery.dart'; import '../../../shared/me/me_assistants.dart'; import '../../../shared/theme/theme.dart'; @@ -68,6 +69,7 @@ class MePage extends HookConsumerWidget { return null; }, [assistants]); final groups = ref.watch(meGroupsProvider); + final viewer = ref.watch(myPubkeyProvider); final offlineSaveFailed = ref.watch(meOfflineSaveWarningProvider).value ?? false; final syncClosed = ref.watch(meSyncWarningProvider).value ?? false; @@ -196,7 +198,7 @@ class MePage extends HookConsumerWidget { (c) => c.isMember && !c.isArchived && - organization.channels.contains(c.id), + c.isPersonalChat(viewer, organization.channels), ) .toList() ..sort( @@ -229,8 +231,7 @@ class MePage extends HookConsumerWidget { Grid.gutter - Grid.xxs, topSectionHeight + Grid.xxs, Grid.gutter - Grid.xxs, - organization.channels.isEmpty && - organization.groups.isEmpty + visible.isEmpty && organization.groups.isEmpty ? 0 : MediaQuery.viewPaddingOf(context).bottom + Grid.gutter, @@ -297,7 +298,7 @@ class MePage extends HookConsumerWidget { ), ], ), - if (organization.channels.isNotEmpty || + if (visible.isNotEmpty || organization.groups.isNotEmpty) MeSection( key: const ValueKey('me-ungrouped-chats'), @@ -324,8 +325,7 @@ class MePage extends HookConsumerWidget { ], ), ), - if (organization.channels.isEmpty && - organization.groups.isEmpty) + if (visible.isEmpty && organization.groups.isEmpty) SliverFillRemaining( hasScrollBody: false, child: Padding( diff --git a/mobile/lib/features/channels/me/me_unread_provider.dart b/mobile/lib/features/channels/me/me_unread_provider.dart index d4df38591f..a50f5c1456 100644 --- a/mobile/lib/features/channels/me/me_unread_provider.dart +++ b/mobile/lib/features/channels/me/me_unread_provider.dart @@ -28,7 +28,7 @@ class MeUnreadChannelIds extends Notifier> { (c) => c.isMember && !c.isArchived && - organization.channels.contains(c.id), + c.isPersonalChat(reads.pubkey, organization.channels), ) .toList(); final pending = active.where((c) => !seeded.contains(c.id)).toList(); diff --git a/mobile/lib/features/channels/me/native_new_me_chat.dart b/mobile/lib/features/channels/me/native_new_me_chat.dart index 0920284bcc..2192fed74b 100644 --- a/mobile/lib/features/channels/me/native_new_me_chat.dart +++ b/mobile/lib/features/channels/me/native_new_me_chat.dart @@ -155,6 +155,7 @@ Future _createMeChat( name: name, channelType: 'stream', visibility: 'private', + labels: const ['buzz.solo'], ), hasAgent: () async { ref.invalidate(channelMembersProvider(channelId)); diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 6f8922f15b..83ecda9a36 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -276,6 +276,12 @@ class ChannelData { final String id; final String name; final String channelType; + + /// NIP-CL labels in `t` tags after the leading channel-type tag. + final List labels; + + /// Original channel creator from the relay-authored uppercase `P` tag. + final String? creator; final String visibility; final String description; final String? topic; @@ -291,6 +297,8 @@ class ChannelData { required this.visibility, required this.description, this.topic, + this.labels = const [], + this.creator, this.participantPubkeys = const [], this.ttlSeconds, this.ttlDeadline, @@ -336,6 +344,12 @@ class ChannelData { id: id, name: name, channelType: channelType, + labels: event.tags + .where((t) => t.length >= 2 && t[0] == 't') + .skip(1) + .map((t) => t[1]) + .toList(), + creator: event.getTagValue('P'), visibility: visibility, description: description, topic: topic, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 1f9ca1e029..ff77a0b220 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -234,6 +234,7 @@ NostrEvent _edit({ ); Widget _buildTestable({ + bool useRealChannelProviders = false, ProfileNotifier? profileNotifier, String? signingPubkey, bool darkTheme = false, @@ -355,14 +356,16 @@ Widget _buildTestable({ myPubkeyProvider.overrideWithValue(signingPubkey), if (presenceCache != null) presenceCacheProvider.overrideWith(() => presenceCache), - channelsProvider.overrideWith(() => fakeChannelsNotifier), + if (!useRealChannelProviders) + channelsProvider.overrideWith(() => fakeChannelsNotifier), channelStarsProvider.overrideWith(_FakeChannelStarsNotifier.new), channelMutesProvider.overrideWith(_FakeChannelMutesNotifier.new), - channelDetailsProvider(_channelId).overrideWith( - (ref) async => - channelDetails?.call() ?? - ChannelDetails.fromChannel(resolvedChannel), - ), + if (!useRealChannelProviders) + channelDetailsProvider(_channelId).overrideWith( + (ref) async => + channelDetails?.call() ?? + ChannelDetails.fromChannel(resolvedChannel), + ), channelCanvasProvider(_channelId).overrideWith( (ref) async => ChannelCanvas( content: canvasContent, @@ -561,6 +564,95 @@ double? effectiveFontSizeForText( } void main() { + testWidgets( + 'refresh replaces stale Solo details before opening and sending', + (tester) async { + final session = _SoloMetadataRelaySession(); + final solo = _RecordingOrdinarySender(); + await tester.pumpWidget( + _buildTestable( + messages: [], + personalChat: true, + signingPubkey: session.creator, + useRealChannelProviders: true, + disableRetries: true, + relaySessionNotifier: session, + organization: Stream.value(const MeGroups()), + soloSender: solo, + members: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + home: Consumer( + builder: (context, ref, _) => Scaffold( + body: TextButton( + onPressed: () async { + await ref.read(channelsProvider.notifier).refresh(); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage( + channel: ref.read(channelsProvider).requireValue.single, + personalChat: true, + ), + ), + ); + }, + child: const Text('Open refreshed Solo'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.text('Open refreshed Solo')), + ); + final initial = container.read(channelsProvider.future); + await tester.pumpAndSettle(); + await initial; + final details = container.read(channelDetailsProvider(_channelId).future); + await tester.pumpAndSettle(); + final stale = await details; + expect(stale.labels, isEmpty); + session.solo = true; + await tester.tap(find.text('Open refreshed Solo')); + await tester.pumpAndSettle(); + await tester.widget(find.byType(ComposeBar)).onSend( + 'After refresh', + ['agent'], + ); + expect(solo.sent, ['After refresh']); + expect( + (await container.read( + channelDetailsProvider(_channelId).future, + )).labels, + ['buzz.solo'], + ); + session.solo = false; + final refresh = container.read(channelsProvider.notifier).refresh(); + await tester.pumpAndSettle(); + await refresh; + expect( + (await container.read( + channelDetailsProvider(_channelId).future, + )).labels, + isEmpty, + ); + await expectLater( + tester.widget(find.byType(ComposeBar)).onSend( + 'After conversion', + ['agent'], + ), + throwsStateError, + ); + expect(solo.sent, ['After refresh']); + }, + ); + _loadingReviewTests(); threadReplyRefreshTests(); threadTitleCapsuleTests(); @@ -10719,6 +10811,106 @@ void main() { expect(sender.sent, isEmpty); }); + for (final own in [true, false]) { + testWidgets('label-only Solo uses owner-aware sending: $own', ( + tester, + ) async { + final ordinary = _RecordingOrdinarySender(), + solo = _RecordingOrdinarySender(); + final channel = Channel( + id: _channelId, + name: 'Labeled Solo', + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + description: '', + createdBy: own ? 'self' : 'other', + createdAt: DateTime(2026), + memberCount: 2, + isMember: true, + ); + await tester.pumpWidget( + _buildTestable( + messages: [], + channel: channel, + signingPubkey: 'self', + personalChat: own, + organization: Stream.value(const MeGroups()), + sender: ordinary, + soloSender: solo, + members: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + home: ChannelDetailPage(channel: channel), + ), + ); + await tester.pumpAndSettle(); + await tester + .widget(find.byType(ComposeBar)) + .onSend('Label message', own ? ['agent'] : []); + expect(own ? solo.sent : ordinary.sent, ['Label message']); + expect(own ? ordinary.sent : solo.sent, isEmpty); + }); + } + + testWidgets( + 'foreign Solo cannot force the personal sender through a route hint', + (tester) async { + final ordinary = _RecordingOrdinarySender(), + solo = _RecordingOrdinarySender(); + final channel = Channel( + id: _channelId, + name: 'Shared Solo', + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + description: '', + createdBy: 'other', + createdAt: DateTime(2026), + memberCount: 2, + isMember: true, + ); + await tester.pumpWidget( + _buildTestable( + messages: [], + channel: channel, + signingPubkey: 'self', + personalChat: true, + organization: Stream.value(const MeGroups(channels: [_channelId])), + sender: ordinary, + soloSender: solo, + members: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + home: ChannelDetailPage(channel: channel, personalChat: true), + ), + ); + await tester.pumpAndSettle(); + await expectLater( + tester.widget(find.byType(ComposeBar)).onSend('Rejected', [ + 'agent', + ]), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Refresh Solo before sending to this chat.', + ), + ), + ); + expect(solo.sent, isEmpty); + expect(ordinary.sent, isEmpty); + }, + ); + testWidgets('identified Solo sends through the Solo sender', (tester) async { final ordinary = _RecordingOrdinarySender(), solo = _RecordingOrdinarySender(); @@ -17882,3 +18074,76 @@ class _RecordingOrdinarySender implements SendMessage { sent.add(content); } } + +class _SoloMetadataRelaySession extends RelaySessionNotifier { + final fixture = + jsonDecode(File('../test-fixtures/solo-creation.json').readAsStringSync()) + as Map; + String get creator => fixture['creator'] as String; + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async => [ + for (final filter in filters) + ...await fetchHistory(filter, timeout: timeout), + ]; + bool solo = false; + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filter.kinds.contains(39002)) { + return [ + NostrEvent( + id: 'membership', + pubkey: 'relay', + createdAt: 1, + kind: 39002, + tags: [ + ['d', _channelId], + ['p', creator], + ], + content: '', + sig: '', + ), + ]; + } + if (filter.kinds.contains(39000)) { + return [ + NostrEvent( + id: solo ? 'new' : 'old', + pubkey: 'relay', + createdAt: solo ? 2 : 1, + kind: 39000, + tags: [ + ['d', _channelId], + ['name', 'Solo'], + ['private'], + if (solo) ...[ + for (final tag in fixture['identity_tags'] as List) + List.from(tag as List), + ] else ...[ + ['t', 'stream'], + ['P', creator], + ], + ], + content: '', + sig: '', + ), + ]; + } + return []; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function()? onEose, + void Function(String)? onClosed, + }) async => () {}; +} diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index f12ca104ae..4fc8b82774 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/features/channels/channel.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/channel_management_provider.dart'; @@ -13,6 +14,52 @@ import 'package:buzz/shared/relay/relay.dart'; /// also exposed on `ChannelDetails` MUST be propagated here — otherwise /// `mergeDetails` silently clears that state on the merged Channel. void main() { + test( + 'Solo metadata survives details merges and classifies only own private streams', + () { + for (final type in ['stream', 'forum', 'dm']) { + for (final private in [true, false]) { + final details = channelDetailsFromEvent( + NostrEvent( + id: 'metadata', + pubkey: 'relay', + createdAt: 1, + kind: 39000, + tags: [ + ['d', 'chat'], + ['t', type], + ['t', 'buzz.solo'], + ['P', 'owner'], + [private ? 'private' : 'public'], + ], + content: '', + sig: '', + ), + ); + final channel = Channel( + id: 'chat', + name: '', + channelType: 'stream', + visibility: 'private', + description: '', + createdBy: 'relay', + createdAt: DateTime(2026), + memberCount: 1, + ).mergeDetails(details).copyWith(isMember: true); + expect(channel.labels, ['buzz.solo']); + expect(channel.createdBy, 'owner'); + expect(channel.isSolo, type == 'stream' && private); + expect( + channel.isPersonalChat('owner', const []), + type == 'stream' && private, + ); + expect(channel.isPersonalChat('other', const []), isFalse); + expect(ChannelDetails.fromChannel(channel).labels, ['buzz.solo']); + } + } + }, + ); + test('extracts unique relay members from current and legacy tags', () { final pubkeys = relayMemberPubkeysFromEvents([ NostrEvent( @@ -220,6 +267,28 @@ void main() { }); group('buildCreateChannelTags', () { + test( + 'Solo creation carries its discovery label without client creator claims', + () { + final tags = buildCreateChannelTags( + channelId: _channelId, + name: 'Solo', + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + ); + expect(tags, contains(equals(['t', 'buzz.solo']))); + expect(tags.where((tag) => tag.first == 'P'), isEmpty); + final ordinary = buildCreateChannelTags( + channelId: _channelId, + name: 'Private channel', + channelType: 'stream', + visibility: 'private', + ); + expect(ordinary.where((tag) => tag.first == 't'), isEmpty); + }, + ); + test('builds an ongoing channel without a ttl tag', () { final tags = buildCreateChannelTags( channelId: 'c8c629ae-d35c-44fa-bc39-f6c1816756cc', diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart index dcb9d21958..60a7a895db 100644 --- a/mobile/test/features/channels/me/me_page_test.dart +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -40,7 +40,65 @@ class FakeChannels extends ChannelsNotifier { ]; } +class LabeledChannels extends ChannelsNotifier { + @override + Future> build() async => [ + for (final (id, name, owner, member) in [ + ('filed', 'Filed labeled chat', 'owner', true), + ('loose', 'Unfiled labeled chat', 'owner', true), + ('shared', 'Someone else chat', 'other', true), + ('hidden', 'Inaccessible labeled chat', 'owner', false), + ]) + Channel( + id: id, + name: name, + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + description: '', + createdBy: owner, + createdAt: DateTime(2026), + memberCount: 2, + isMember: member, + ), + ]; +} + void main() { + testWidgets('label-only desktop chats appear in projects and unfiled chats', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + myPubkeyProvider.overrideWithValue('owner'), + channelsProvider.overrideWith(LabeledChannels.new), + meGroupsProvider.overrideWith( + (ref) => Stream.value( + const MeGroups( + groups: [ + { + 'id': 'project', + 'name': 'App work', + 'defaultTemplateId': '', + }, + ], + assignments: {'filed': 'project'}, + ), + ), + ), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Filed labeled chat'), findsOneWidget); + expect(find.text('Unfiled labeled chat'), findsOneWidget); + expect(find.text('Someone else chat'), findsNothing); + expect(find.text('Inaccessible labeled chat'), findsNothing); + expect(find.text('A place to work with your agents'), findsNothing); + }); + testWidgets('empty workspace stays centered behind the New chat keyboard', ( tester, ) async { diff --git a/mobile/test/features/channels/me/solo_creation_tags_test.dart b/mobile/test/features/channels/me/solo_creation_tags_test.dart new file mode 100644 index 0000000000..c880e5457a --- /dev/null +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/me/me_page.dart'; +import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/me/me_groups.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _EmptyChannels extends ChannelsNotifier { + @override + Future> build() async => []; +} + +class _Relay extends RelaySessionNotifier { + final NostrEvent profile; + final submitted = []; + _Relay(this.profile); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async => [profile]; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => []; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + submitted.add(event); + // Stop at the transport boundary: this test covers the real creation + // command, not relay acceptance or the subsequent membership writes. + throw StateError('Captured creation command'); + } +} + +void main() { + testWidgets( + 'New Solo chat submits its discovery label through the production action', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final owner = nostr.Keys.generate(), agent = nostr.Keys.generate(); + final digest = SHA256Digest() + .process( + Uint8List.fromList( + utf8.encode('nostr:agent-auth:${agent.public}:'), + ), + ) + .map((v) => v.toRadixString(16).padLeft(2, '0')) + .join(); + final profile = NostrEvent.fromJson( + nostr.Event.from( + kind: 0, + content: jsonEncode({'display_name': 'Owned agent'}), + secretKey: agent.secret, + tags: [ + [ + 'auth', + owner.public, + '', + nostr.Schnorr.sign(secretKey: owner.secret, message: digest), + ], + ], + ).toMap(), + ); + final relay = _Relay(profile); + final config = RelayConfig( + baseUrl: 'https://relay.test', + nsec: owner.nsec, + ); + final sync = MeSync(relay, config, prefs: prefs); + const bridge = MethodChannel('buzz/profile_text_editor'); + var forms = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( + call, + ) async { + if (++forms > 1) return null; + return {'name': 'Mobile Solo', 'assistant': agent.public}; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + bridge, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meSyncProvider.overrideWithValue(sync), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + meAssistantsProvider.overrideWith((ref) async => []), + channelsProvider.overrideWith(_EmptyChannels.new), + channelActionsProvider.overrideWith( + (ref) => ChannelActions( + ref: ref, + session: relay, + signedEventRelay: SignedEventRelay( + session: relay, + nsec: owner.nsec, + ), + currentPubkey: owner.public, + ), + ), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + tester + .widget(find.byType(FrostedAppBar)) + .nativeActions! + .firstWhere((action) => action.label == 'New chat') + .onPressed!(); + await tester.pumpAndSettle(); + final command = relay.submitted.single; + expect(command.kind, 9007); + expect(command.pubkey, owner.public); + expect(command.tags, contains(equals(['t', 'buzz.solo']))); + expect(command.tags, contains(equals(['channel_type', 'stream']))); + expect(command.tags, contains(equals(['visibility', 'private']))); + expect(command.tags.where((tag) => tag.first == 'P'), isEmpty); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); +} diff --git a/scripts/contracts/capture-solo-creation.mjs b/scripts/contracts/capture-solo-creation.mjs new file mode 100644 index 0000000000..38e2c04b74 --- /dev/null +++ b/scripts/contracts/capture-solo-creation.mjs @@ -0,0 +1,67 @@ +// Capture the current desktop's production create call, without a relay write. +// Usage: node --experimental-transform-types scripts/contracts/capture-solo-creation.mjs /path/to/buzz-app +import { registerHooks } from "node:module"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import assert from "node:assert/strict"; +const root = resolve(process.argv[2]); +execFileSync("git", ["-C", root, "diff", "--exit-code", "HEAD", "--", "src"]); +registerHooks({ + resolve(specifier, context, next) { + try { + return next(specifier, context); + } catch (error) { + if (specifier.startsWith(".") && !/\.[a-z]+$/.test(specifier)) + return next(`${specifier}.ts`, context); + throw error; + } + }, +}); +const { createChannelWrites } = await import( + pathToFileURL(`${root}/src/features/relay/channel-writes.ts`) +); +let creation; +const outbox = { + supports: () => true, + send: (event) => { + creation = event; + return "captured"; + }, +}; +const reader = {}; +createChannelWrites( + outbox, + {}, + reader, + new AbortController().signal, + undefined, + undefined, + undefined, + undefined, + reader, + true, +).create("11111111-1111-4111-8111-111111111111", "Desktop Solo"); +assert.equal(creation.kind, 9007); +assert.ok( + creation.tags.some(([key, value]) => key === "t" && value === "buzz.solo"), +); +console.log( + JSON.stringify( + { + producer: "https://github.com/block/buzz-app", + revision: execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(), + creation, + creator: "11".repeat(32), + identity_tags: [ + ["t", "stream"], + ["t", "buzz.solo"], + ["P", "11".repeat(32)], + ], + }, + null, + 2, + ), +); diff --git a/test-fixtures/solo-creation.json b/test-fixtures/solo-creation.json new file mode 100644 index 0000000000..7b364bc3c8 --- /dev/null +++ b/test-fixtures/solo-creation.json @@ -0,0 +1,21 @@ +{ + "producer": "https://github.com/block/buzz-app", + "revision": "64726576ed3b082daa5786e5e7ca6b5a018663a1", + "creation": { + "kind": 9007, + "content": "", + "tags": [ + ["h", "11111111-1111-4111-8111-111111111111"], + ["name", "Desktop Solo"], + ["visibility", "private"], + ["channel_type", "stream"], + ["t", "buzz.solo"] + ] + }, + "creator": "1111111111111111111111111111111111111111111111111111111111111111", + "identity_tags": [ + ["t", "stream"], + ["t", "buzz.solo"], + ["P", "1111111111111111111111111111111111111111111111111111111111111111"] + ] +}