fix(mobile): add mentioned agents to channels (#1696)

Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-07-13 16:17:01 -07:00
committed by GitHub
parent fdd366bc18
commit 3112e59fd8
6 changed files with 391 additions and 12 deletions

View File

@@ -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,

View File

@@ -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<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);
@@ -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,34 @@ 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 selectedAgentPubkeys = LinkedHashSet<String>.from(
selectedMentions
.where((candidate) => candidate.isAgent)
.map((candidate) => candidate.pubkey.toLowerCase()),
);
final nonMemberAgentPubkeys = <String>[];
if (selectedAgentPubkeys.isNotEmpty) {
final currentChannel = (await ref.read(
channelsProvider.future,
)).firstWhere((channel) => channel.id == channelId);
if (!currentChannel.isDm) {
final memberPubkeys = (await ref.read(
channelMembersProvider(channelId).future,
)).map((member) => member.pubkey.toLowerCase()).toSet();
nonMemberAgentPubkeys.addAll(
selectedAgentPubkeys.where(
(pubkey) => !memberPubkeys.contains(pubkey),
),
);
}
}
final payload = _ComposeDraftPayload.fromDraft(
text: text,
@@ -282,6 +310,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();

View File

@@ -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

View File

@@ -294,6 +294,17 @@ class RelaySessionNotifier extends Notifier<SessionState> {
@visibleForTesting
void debugPauseNow() => _pauseNow();
@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();

View File

@@ -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';
@@ -219,6 +220,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;

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -13,6 +14,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 +112,26 @@ void _setMockMediaUploadPlatformHandler(
Widget _buildComposeBar({
required MediaUploadService uploadService,
required ComposeBarOnSend onSend,
List<ChannelMember> members = const <ChannelMember>[],
Future<List<ChannelMember>>? membersFuture,
List<AgentDirectoryEntry> relayAgents = const <AgentDirectoryEntry>[],
List<Channel> channels = const <Channel>[],
String? currentPubkey,
}) {
return ProviderScope(
overrides: [
mediaUploadServiceProvider.overrideWithValue(uploadService),
currentPubkeyProvider.overrideWith((ref) => null),
currentPubkeyProvider.overrideWith((ref) => currentPubkey),
channelMembersProvider(
'channel-1',
).overrideWith((ref) async => const <ChannelMember>[]),
agentDirectoryProvider.overrideWith(
(ref) async => const <AgentDirectoryEntry>[],
),
).overrideWith((ref) => membersFuture ?? Future.value(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(),
@@ -137,6 +144,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();
@@ -361,6 +422,181 @@ 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: [_makeCurrentChannel(), _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('does not mutate a DM when mentioning a non-member agent', (
tester,
) async {
final agentPubkey = 'd' * 64;
final signer = nostr.Keys.generate();
final publishedEvents = <Map<String, dynamic>>[];
String? sentContent;
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(signer.nsec),
currentPubkey: signer.public,
relayAgents: [_testAgent(agentPubkey)],
channels: [
_makeCurrentChannel(channelType: 'dm'),
_makeSharedMemberChannel(),
],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {
sentContent = content;
},
),
);
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 _selectAndSendAgentMention(tester);
expect(sentContent, 'hello @Helper Bot');
expect(publishedEvents.where((event) => event['kind'] == 9000), isEmpty);
});
testWidgets('waits for current member data before adding an agent', (
tester,
) async {
final agentPubkey = 'e' * 64;
final signer = nostr.Keys.generate();
final membersCompleter = Completer<List<ChannelMember>>();
final publishedEvents = <Map<String, dynamic>>[];
var didSend = false;
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(signer.nsec),
currentPubkey: signer.public,
membersFuture: membersCompleter.future,
relayAgents: [_testAgent(agentPubkey)],
channels: [_makeCurrentChannel(), _makeSharedMemberChannel()],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {
didSend = true;
},
),
);
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.pump();
expect(didSend, isFalse);
expect(publishedEvents.where((event) => event['kind'] == 9000), isEmpty);
membersCompleter.complete([
ChannelMember(
pubkey: agentPubkey,
role: 'admin',
joinedAt: DateTime(2024),
),
]);
await tester.pumpAndSettle();
expect(didSend, isTrue);
expect(publishedEvents.where((event) => event['kind'] == 9000), isEmpty);
});
testWidgets('shows a clean error when an animated PNG is picked', (
tester,
) async {
@@ -676,6 +912,62 @@ void main() {
});
}
MediaUploadService _testUploadService(String nsec) {
return MediaUploadService(
baseUrl: 'https://relay.example',
nsec: nsec,
pickGalleryImage: () async => null,
pickGalleryVideo: () async => null,
);
}
AgentDirectoryEntry _testAgent(String pubkey) {
return AgentDirectoryEntry(
pubkey: pubkey,
displayName: 'Helper Bot',
respondTo: 'anyone',
channelIds: const ['shared-channel'],
);
}
Future<void> _selectAndSendAgentMention(WidgetTester tester) async {
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();
}
Channel _makeCurrentChannel({String channelType = 'stream'}) {
return Channel(
id: 'channel-1',
name: 'current',
channelType: channelType,
visibility: 'open',
description: '',
createdBy: 'pubkey123',
createdAt: DateTime(2024),
memberCount: 2,
isMember: true,
);
}
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',