mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user