Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions mobile/lib/features/channels/channel_management_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,33 @@ class ChannelActions {
return _refreshChannelsAndRead(channelId);
}

Future<void> addMembers({
required String channelId,
required List<String> 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<void> joinChannel(String channelId) async {
await _signedEventRelay.submit(
kind: 9021,
Expand Down
35 changes: 28 additions & 7 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -75,9 +77,10 @@ class ComposeBar extends HookConsumerWidget {
// Mention state --------------------------------------------------------
final mentionQuery = useState<String?>(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(<String, String>{});
// 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(<String, MentionCandidate>{});

// Channel autocomplete state ----------------------------------------------
final channelQuery = useState<String?>(null);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -269,10 +273,18 @@ class ComposeBar extends HookConsumerWidget {
}

// Extract pubkeys for mentions present in the final text.
final pubkeys = <String>[
final selectedMentions = <MentionCandidate>[
for (final entry in mentionMap.value.entries)
if (text.contains('@${entry.key}')) entry.value,
if (hasMention(text, entry.key)) entry.value,
];
final pubkeys = LinkedHashSet<String>.from(
selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
).toList();
final nonMemberAgentPubkeys = LinkedHashSet<String>.from(
selectedMentions
.where((candidate) => candidate.isAgent && !candidate.isMember)
.map((candidate) => candidate.pubkey.toLowerCase()),
).toList();

final payload = _ComposeDraftPayload.fromDraft(
text: text,
Expand All @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions mobile/lib/features/channels/compose_bar/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions mobile/lib/shared/relay/relay_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,17 @@ class RelaySessionNotifier extends Notifier<SessionState> {
@visibleForTesting
void debugFlushEventBuffer() => _flushEventBuffer();

@visibleForTesting
void debugHandleSocketMessageForTest(List<dynamic> 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<void> reconnect() async {
await _socket?.disconnect();
Expand Down
4 changes: 4 additions & 0 deletions mobile/lib/shared/relay/relay_socket.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -205,6 +206,9 @@ class RelaySocket {
}
}

@visibleForTesting
void debugHandleOkForTest(List<dynamic> data) => _onMessage(data);

/// Handle OK frames. During auth, complete the auth flow.
void _handleOk(List<dynamic> data) {
if (data.length < 3) return;
Expand Down
155 changes: 148 additions & 7 deletions mobile/test/features/channels/compose_bar_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -110,21 +111,23 @@ void _setMockMediaUploadPlatformHandler(
Widget _buildComposeBar({
required MediaUploadService uploadService,
required ComposeBarOnSend onSend,
List<ChannelMember> members = const <ChannelMember>[],
List<AgentDirectoryEntry> relayAgents = const <AgentDirectoryEntry>[],
List<Channel> channels = const <Channel>[],
String? currentPubkey,
}) {
return ProviderScope(
overrides: [
mediaUploadServiceProvider.overrideWithValue(uploadService),
currentPubkeyProvider.overrideWith((ref) => null),
channelMembersProvider(
'channel-1',
).overrideWith((ref) async => const <ChannelMember>[]),
agentDirectoryProvider.overrideWith(
(ref) async => const <AgentDirectoryEntry>[],
),
currentPubkeyProvider.overrideWith((ref) => currentPubkey),
channelMembersProvider('channel-1').overrideWith((ref) async => members),
agentDirectoryProvider.overrideWith((ref) async => relayAgents),
agentOwnersProvider.overrideWith((ref) async => const <String, String>{}),
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
),
relayConfigProvider.overrideWith(() => _FakeRelayConfigNotifier()),
channelsProvider.overrideWith(() => _FakeChannelsNotifier(channels)),
],
child: MaterialApp(
theme: AppTheme.light(),
Expand All @@ -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<Map<String, dynamic>> events;
final void Function(List<dynamic> 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<dynamic> payload) {
if (payload case ['EVENT', final Map<String, dynamic> event]) {
events.add(event);
final id = event['id'] as String;
super.debugHandleOkForTest(['OK', id, true, '']);
}
}

@override
Future<void> disconnect() async {}

@override
void dispose() {}
}

class _FakeChannelsNotifier extends ChannelsNotifier {
final List<Channel> _channels;

_FakeChannelsNotifier(this._channels);

@override
Future<List<Channel>> build() async => _channels;

@override
Future<void> refresh() async {
state = AsyncData(_channels);
}
}

void main() {
TestWidgetsFlutterBinding.ensureInitialized();

Expand Down Expand Up @@ -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 = <Map<String, dynamic>>[];
final uploadService = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: signer.nsec,
pickGalleryImage: () async => null,
pickGalleryVideo: () async => null,
);
String? sentContent;
List<String> sentMentionPubkeys = const <String>[];

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 <List<String>>[],
}) 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 {
Expand Down Expand Up @@ -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',
Expand Down
Loading