From 3b8997fa34e8ceedf3f801b79e07cb5ec8fe4391 Mon Sep 17 00:00:00 2001 From: ik24999 Date: Thu, 6 Aug 2026 00:25:40 +0200 Subject: [PATCH] fix(mobile): tag DM recipients Co-authored-by: Iker Signed-off-by: Iker --- .../channels/send_message_provider.dart | 28 ++++++- .../channels/send_message_provider_test.dart | 84 +++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 730546807f..d3d435df8c 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -4,13 +4,16 @@ import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'channel.dart'; import 'channel_messages_provider.dart'; +import 'channels_provider.dart'; /// Sends messages by signing an event with the user's nsec and publishing it /// over the relay's NIP-42-authenticated WebSocket session. class SendMessage { final SignedEventRelay _signedEventRelay; final Future> Function(String channelId) _fetchMembers; + final Channel? Function(String channelId) _readChannel; final Map Function() _readUserCache; final void Function(String channelId, NostrEvent event) _addLocalMessage; final void Function(String channelId, String eventId) _completeLocalMessage; @@ -21,6 +24,7 @@ class SendMessage { required SignedEventRelay signedEventRelay, required Future> Function(String channelId) fetchMembers, + required Channel? Function(String channelId) readChannel, required Map Function() readUserCache, required void Function(String channelId, NostrEvent event) addLocalMessage, required void Function(String channelId, String eventId) @@ -29,6 +33,7 @@ class SendMessage { bool Function()? isDeliveryValid, }) : _signedEventRelay = signedEventRelay, _fetchMembers = fetchMembers, + _readChannel = readChannel, _readUserCache = readUserCache, _addLocalMessage = addLocalMessage, _completeLocalMessage = completeLocalMessage, @@ -56,14 +61,21 @@ class SendMessage { final resolvedMentions = mentionPubkeys ?? await _resolveMentions(content, channelId); final authorPubkey = _signedEventRelay.pubkey; + final channel = _readChannel(channelId); + final recipientPubkeys = [ + ...resolvedMentions, + if (channel?.isDm == true) ...channel!.participantPubkeys, + ]; - // Normalize mentions: lowercase, deduplicate, exclude self (matching - // the desktop's normalizeMentionPubkeys). + // A DM addresses every other participant even when the composer text has + // no @mention. Agent harnesses and human notification subscriptions rely + // on those p tags, matching the desktop's messageMentionPubkeys. final selfLower = authorPubkey?.toLowerCase(); final seenMentions = {?selfLower}; final normalizedMentions = [ - for (final pk in resolvedMentions) - if (seenMentions.add(pk.toLowerCase())) pk, + for (final pk in recipientPubkeys) + if (pk.trim().isNotEmpty && seenMentions.add(pk.trim().toLowerCase())) + pk.trim().toLowerCase(), ]; final tags = >[ @@ -182,6 +194,14 @@ final sendMessageProvider = Provider((ref) { ), fetchMembers: (channelId) => ref.read(channelMembersProvider(channelId).future), + readChannel: (channelId) { + final channels = ref.read(channelsProvider).value; + if (channels == null) return null; + for (final channel in channels) { + if (channel.id == channelId) return channel; + } + return null; + }, readUserCache: () => ref.read(userCacheProvider), addLocalMessage: (channelId, event) => ref .read(channelMessagesProvider(channelId).notifier) diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index 273336f283..96b835d440 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/send_message_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -20,6 +21,7 @@ void main() { nsec: nostr.Keys.generate().nsec, ), fetchMembers: (_) async => const [], + readChannel: (_) => _streamChannel, readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), completeLocalMessage: (_, eventId) => completedIds.add(eventId), @@ -53,6 +55,7 @@ void main() { nsec: nostr.Keys.generate().nsec, ), fetchMembers: (_) async => const [], + readChannel: (_) => _streamChannel, readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), completeLocalMessage: (_, eventId) => completedIds.add(eventId), @@ -68,6 +71,75 @@ void main() { expect(removedIds, [localMessages.single.id]); }); + test('adds every other DM participant as a p tag', () async { + final session = _PendingPublishRelaySession(); + final keys = nostr.Keys.generate(); + const recipient = + 'ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789'; + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: keys.nsec), + fetchMembers: (_) async => const [], + readChannel: (_) => Channel( + id: _channelId, + name: 'Direct message', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: keys.public, + createdAt: DateTime.utc(2026), + memberCount: 2, + participantPubkeys: [recipient, keys.public, recipient], + isMember: true, + ), + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello without a mention', + mentionPubkeys: const [], + ); + await session.published; + + expect(session.event.tags.where((tag) => tag.first == 'p'), [ + ['p', recipient.toLowerCase()], + ]); + + session.accept(); + await result; + }); + + test('does not add channel participants to stream messages', () async { + final session = _PendingPublishRelaySession(); + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: nostr.Keys.generate().nsec, + ), + fetchMembers: (_) async => const [], + readChannel: (_) => _streamChannel, + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello without a mention', + mentionPubkeys: const [], + ); + await session.published; + + expect(session.event.tags.where((tag) => tag.first == 'p'), isEmpty); + + session.accept(); + await result; + }); + test('cancels delivery after the active community changes', () async { final container = ProviderContainer(); addTearDown(container.dispose); @@ -94,6 +166,18 @@ void main() { } const _channelId = '11111111-1111-4111-8111-111111111111'; +final _streamChannel = Channel( + id: _channelId, + name: 'General', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'creator', + createdAt: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + memberCount: 2, + participantPubkeys: ['participant'], + isMember: true, +); class _PendingPublishRelaySession extends RelaySessionNotifier { final Completer _result = Completer();