diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 2f2539e3f1..3a77f7404e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -131,7 +131,8 @@ const overrides = new Map([ // databricks-v1-to-v2-migration: databricks-v2 hyphen-alias added to all // host/credential match arms + 30+ readiness tests for provider aliases, // missing-host, and DATABRICKS_MODEL fallback. Load-bearing correctness fix. - ["src-tauri/src/managed_agents/readiness.rs", 1546], + // +3 lines landed on main after the last ratchet; preserve current headroom. + ["src-tauri/src/managed_agents/readiness.rs", 1549], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index bb2e14636d..0ac7315794 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -281,6 +281,33 @@ class ChannelActions { return _refreshChannelsAndRead(channelId); } + Future addMembers({ + required String channelId, + required List pubkeys, + String role = 'member', + }) async { + final normalizedRole = role.trim(); + if (normalizedRole.isEmpty) { + throw ArgumentError.value(role, 'role', 'must not be empty'); + } + final normalizedPubkeys = { + for (final pubkey in pubkeys) + if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(), + }; + for (final pubkey in normalizedPubkeys) { + await _signedEventRelay.submit( + kind: 9000, + content: '', + tags: [ + ['h', channelId], + ['p', pubkey], + ['role', normalizedRole], + ], + ); + } + _ref.invalidate(channelMembersProvider(channelId)); + } + Future joinChannel(String channelId) async { await _signedEventRelay.submit( kind: 9021, diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 2eaae9a41e..271b3d18ef 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -75,9 +77,10 @@ class ComposeBar extends HookConsumerWidget { // Mention state -------------------------------------------------------- final mentionQuery = useState(null); final mentionStartIdx = useState(-1); - // Map of displayName → pubkey built as the user selects mentions. - // Used to pass resolved pubkeys directly to onSend, avoiding regex. - final mentionMap = useRef({}); + // Map of displayName → selected mention candidate built as the user selects + // mentions. Used to pass resolved pubkeys directly to onSend and to attach + // selected non-member agents before the message is published. + final mentionMap = useRef({}); // Channel autocomplete state ---------------------------------------------- final channelQuery = useState(null); @@ -213,8 +216,9 @@ class ComposeBar extends HookConsumerWidget { // Insert a selected mention into the text field. void insertMention(MentionCandidate candidate) { final name = candidate.label; - // Track the resolved pubkey so we can pass it at send time. - mentionMap.value[name] = candidate.pubkey; + // Track the resolved candidate so we can pass its pubkey and prepare + // selected non-member agents at send time. + mentionMap.value[name] = candidate; final start = mentionStartIdx.value.clamp(0, controller.text.length); spliceAndMoveCursor( @@ -269,10 +273,18 @@ class ComposeBar extends HookConsumerWidget { } // Extract pubkeys for mentions present in the final text. - final pubkeys = [ + final selectedMentions = [ for (final entry in mentionMap.value.entries) - if (text.contains('@${entry.key}')) entry.value, + if (hasMention(text, entry.key)) entry.value, ]; + final pubkeys = LinkedHashSet.from( + selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()), + ).toList(); + final nonMemberAgentPubkeys = LinkedHashSet.from( + selectedMentions + .where((candidate) => candidate.isAgent && !candidate.isMember) + .map((candidate) => candidate.pubkey.toLowerCase()), + ).toList(); final payload = _ComposeDraftPayload.fromDraft( text: text, @@ -282,6 +294,15 @@ class ComposeBar extends HookConsumerWidget { isSending.value = true; try { + if (nonMemberAgentPubkeys.isNotEmpty) { + await ref + .read(channelActionsProvider) + .addMembers( + channelId: channelId, + pubkeys: nonMemberAgentPubkeys, + role: 'bot', + ); + } await onSend(payload.content, pubkeys, mediaTags: payload.mediaTags); if (context.mounted) { clearComposer(); diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index a017ed43bd..1d43a90c14 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -84,6 +84,14 @@ void _insertTriggerAtCursor( focusNode.requestFocus(); } +bool hasMention(String text, String name) { + final pattern = RegExp( + '(?:^|\\s|[*_]{1,3}|\\|\\|)@${RegExp.escape(name)}(?=\\|\\||[\\s,;.!?:)\\]}*_]|\$)', + caseSensitive: false, + ); + return pattern.hasMatch(text); +} + /// Send a typing indicator over the WebSocket (fire-and-forget). /// /// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index b2e3e519bd..9ebf7fe217 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -266,6 +266,17 @@ class RelaySessionNotifier extends Notifier { @visibleForTesting void debugFlushEventBuffer() => _flushEventBuffer(); + @visibleForTesting + void debugHandleSocketMessageForTest(List data) => + _handleMessage(data); + + @visibleForTesting + void debugAttachSocketForTest(RelaySocket socket) { + _socket?.dispose(); + _socket = socket; + state = const SessionState(status: SessionStatus.connected); + } + /// Force a reconnect (e.g., returning from background). Future reconnect() async { await _socket?.disconnect(); diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 9054ddff2e..0142ba1ac6 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -205,6 +206,9 @@ class RelaySocket { } } + @visibleForTesting + void debugHandleOkForTest(List data) => _onMessage(data); + /// Handle OK frames. During auth, complete the auth flow. void _handleOk(List data) { if (data.length < 3) return; diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 00e1236cca..298e5b58c4 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -13,6 +13,7 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/compose_bar.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -110,21 +111,23 @@ void _setMockMediaUploadPlatformHandler( Widget _buildComposeBar({ required MediaUploadService uploadService, required ComposeBarOnSend onSend, + List members = const [], + List relayAgents = const [], + List channels = const [], + String? currentPubkey, }) { return ProviderScope( overrides: [ mediaUploadServiceProvider.overrideWithValue(uploadService), - currentPubkeyProvider.overrideWith((ref) => null), - channelMembersProvider( - 'channel-1', - ).overrideWith((ref) async => const []), - agentDirectoryProvider.overrideWith( - (ref) async => const [], - ), + currentPubkeyProvider.overrideWith((ref) => currentPubkey), + channelMembersProvider('channel-1').overrideWith((ref) async => members), + agentDirectoryProvider.overrideWith((ref) async => relayAgents), agentOwnersProvider.overrideWith((ref) async => const {}), relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), ), + relayConfigProvider.overrideWith(() => _FakeRelayConfigNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier(channels)), ], child: MaterialApp( theme: AppTheme.light(), @@ -137,6 +140,60 @@ Widget _buildComposeBar({ ); } +class _FakeRelayConfigNotifier extends RelayConfigNotifier { + @override + RelayConfig build() => RelayConfig( + baseUrl: 'http://localhost:3000', + nsec: nostr.Keys.generate().nsec, + ); +} + +class _RecordingRelaySocket extends RelaySocket { + final List> events; + final void Function(List message) handleMessage; + + _RecordingRelaySocket(this.events, this.handleMessage) + : super( + wsUrl: 'ws://localhost', + nsec: null, + onMessage: handleMessage, + onConnected: () {}, + onDisconnected: (_) {}, + ); + + @override + SocketState get state => SocketState.connected; + + @override + void send(List payload) { + if (payload case ['EVENT', final Map event]) { + events.add(event); + final id = event['id'] as String; + super.debugHandleOkForTest(['OK', id, true, '']); + } + } + + @override + Future disconnect() async {} + + @override + void dispose() {} +} + +class _FakeChannelsNotifier extends ChannelsNotifier { + final List _channels; + + _FakeChannelsNotifier(this._channels); + + @override + Future> build() async => _channels; + + @override + Future refresh() async { + state = AsyncData(_channels); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -361,6 +418,76 @@ void main() { ); }); + testWidgets('adds a selected non-member agent as a bot before sending', ( + tester, + ) async { + final agentPubkey = 'c' * 64; + final signer = nostr.Keys.generate(); + final publishedEvents = >[]; + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: signer.nsec, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + String? sentContent; + List sentMentionPubkeys = const []; + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + currentPubkey: signer.public, + relayAgents: [ + AgentDirectoryEntry( + pubkey: agentPubkey, + displayName: 'Helper Bot', + respondTo: 'anyone', + channelIds: const ['shared-channel'], + ), + ], + channels: [_makeSharedMemberChannel()], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + sentMentionPubkeys = mentionPubkeys; + }, + ), + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + final socket = _RecordingRelaySocket( + publishedEvents, + session.debugHandleSocketMessageForTest, + ); + session.debugAttachSocketForTest(socket); + + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); + await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.pumpAndSettle(); + + expect(sentContent, 'hello @Helper Bot'); + expect(sentMentionPubkeys, [agentPubkey]); + final addMemberEvent = publishedEvents.singleWhere( + (event) => event['kind'] == 9000, + ); + expect(addMemberEvent['tags'], [ + ['h', 'channel-1'], + ['p', agentPubkey], + ['role', 'bot'], + ]); + }); + testWidgets('shows a clean error when an animated PNG is picked', ( tester, ) async { @@ -676,6 +803,20 @@ void main() { }); } +Channel _makeSharedMemberChannel() { + return Channel( + id: 'shared-channel', + name: 'shared', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'pubkey123', + createdAt: DateTime(2024), + memberCount: 5, + isMember: true, + ); +} + Channel _makeChannel({required String name, required String channelType}) { return Channel( id: 'id-$name',