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"] + ] +}