Skip to content

[Bug] Mobile: @mentions typed without tapping the picker get zero p tags in non-DM channels #7449

Description

@docentesIA

Summary

In a non-DM channel, typing @Name and sending without tapping the autocomplete
suggestion
produces a signed event with zero p tags — silently. The relay
correctly declines to route it to the mentioned member (subscriptions filter on #p),
so nothing looks broken until you inspect the raw event tags.

Reproduced on a live relay with a real channel member: 4/4 plain-text mentions across
two different recipients missed the p tag, while messages where the autocomplete
suggestion was actually tapped worked fine. A DM-type send in the same session did carry
a p tag, but that's unrelated — DM channels tag every recipient unconditionally
(messageMentionPubkeys), independent of picker interaction.

Root cause

ComposeBar (mobile/lib/features/channels/compose_bar/compose_bar_widget.dart)
resolves outgoing mentions from mentionMap, populated only when the user taps a
picker suggestion. The result is a concrete List<String> — possibly empty, never
null
— passed to SendMessage.call().

send_message_provider.dart had:

final explicitMentions =
    mentionPubkeys ?? await _resolveMentions(content, channelId);

Since the compose bar never passes null, _resolveMentions — a working text-based
resolver scoped to channel members, already in the codebase — never runs from the
interactive send path. It's reachable only for callers that explicitly pass null.
This isn't a UX issue with the autocomplete; it's a type mismatch ([] vs null)
making working code unreachable.

I couldn't find an existing issue describing this exact case (I did find #2508 and
#5361, which are adjacent but different: they're about agents missing from the
autocomplete directory across machines/instances — here the recipient is a normal,
already-visible channel member; the mention is simply never captured).

Suggested fix (tested locally, not submitted as a PR — no push access to this repo)

  • SendMessage.call(): union mentionPubkeys (picker taps) with
    _resolveMentions(content, channelId) instead of using one or the other. A
    picker-tapped pubkey is never dropped; a Set union avoids duplicate p tags when
    the same recipient is both tapped and named in text.
  • _resolveMentions(), now actually reachable, gets two safety rules consistent with
    its own existing "avoid matching the wrong user" contract:
    • A name matched by more than one channel member resolves to nobody rather than an
      arbitrary pick (previous behavior picked whichever profile came first in an
      unordered cache iteration).
    • Fenced code blocks, inline code spans, and blockquoted lines are stripped before
      extraction — mirrors the idea proposed for the Rust CLI parser in fix(mentions): resolve emphasis/spoiler-wrapped @mentions in CLI (#2526) #2684 ("remove
      code regions before @name extraction"), which the mobile side never had at all.

No signature changes. No other call site affected (forum/ posts don't go through
SendMessage).

Full diff (lib + test) — click to expand
diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart
index 757275c..b3d9e34 100644
--- a/mobile/lib/features/channels/send_message_provider.dart
+++ b/mobile/lib/features/channels/send_message_provider.dart
@@ -61,10 +61,19 @@ class SendMessage {
     List<List<String>> mediaTags = const [],
   }) async {
     _ensureDeliveryValid();
-    // Use explicitly passed pubkeys, or resolve @mentions against
-    // channel members to avoid matching the wrong user.
-    final explicitMentions =
-        mentionPubkeys ?? await _resolveMentions(content, channelId);
+    // Merge explicitly passed pubkeys (picker taps) with any "@Name" typed
+    // as plain text that was never tapped in the picker. The compose bar
+    // always calls this with a concrete (possibly empty) list, never null,
+    // so the old `mentionPubkeys ?? _resolveMentions(...)` fallback never
+    // ran from the interactive send path — only from callers that pass
+    // null explicitly. Union, never replace: a caller-supplied pubkey is
+    // never dropped.
+    final tapped = mentionPubkeys ?? const <String>[];
+    final textResolved = await _resolveMentions(content, channelId);
+    final explicitMentions = <String>{
+      ...tapped.map((pk) => pk.toLowerCase()),
+      ...textResolved,
+    }.toList();
     final authorPubkey = _signedEventRelay.pubkey;
     final dmRecipientPubkeys = channel?.isDm == true
         ? await _fetchDmRecipientPubkeys(channelId, channel!, authorPubkey)
@@ -158,13 +167,23 @@ class SendMessage {
   ///
   /// Fetches channel members from the relay and matches @names only
   /// against members of that channel. Falls back to the full user cache
-  /// if the member fetch fails.
+  /// if the member fetch fails. Two safety rules, both added 2026-09-07
+  /// once this fallback became reachable from the interactive send path
+  /// (see `call` above):
+  /// - Code spans/fences and blockquoted lines are stripped before
+  ///   extraction — mirrors the SDK's "remove code regions before @name
+  ///   extraction" fix (block/buzz#2684); a quoted or sample "@name" is
+  ///   not the sender's live intent to mention anyone.
+  /// - A name matching more than one channel member resolves to nobody
+  ///   rather than an arbitrary pick — same "avoid matching the wrong
+  ///   user" contract this method already documented, made explicit.
   Future<List<String>> _resolveMentions(
     String content,
     String channelId,
   ) async {
+    final scanned = _stripCodeAndQuotes(content);
     final mentionPattern = RegExp(r'@(\w+)');
-    final matches = mentionPattern.allMatches(content);
+    final matches = mentionPattern.allMatches(scanned);
     if (matches.isEmpty) return const [];
 
     // Try to get channel member pubkeys for scoped resolution.
@@ -183,6 +202,7 @@ class SendMessage {
       final name = match.group(1)?.toLowerCase();
       if (name == null || name.isEmpty) continue;
 
+      final candidates = <String>{};
       for (final profile in cache.values) {
         final displayName = profile.displayName?.toLowerCase();
         if (displayName == null) continue;
@@ -197,14 +217,32 @@ class SendMessage {
           continue;
         }
 
-        pubkeys.add(profile.pubkey);
-        break;
+        candidates.add(profile.pubkey.toLowerCase());
       }
+
+      // Exactly one channel member answers to this name: safe to resolve.
+      // Zero or several: skip rather than guess or notify more than one.
+      if (candidates.length == 1) pubkeys.add(candidates.single);
     }
 
     return pubkeys.toList();
   }
 
+  /// Removes fenced code blocks, inline code spans, and blockquoted lines
+  /// before mention extraction. See `_resolveMentions` doc above.
+  static String _stripCodeAndQuotes(String content) {
+    var stripped = content.replaceAll(
+      RegExp(r'```.*?```', dotAll: true),
+      ' ',
+    );
+    stripped = stripped.replaceAll(RegExp(r'`[^`]*`'), ' ');
+    stripped = stripped
+        .split('\n')
+        .where((line) => !line.trimLeft().startsWith('>'))
+        .join('\n');
+    return stripped;
+  }
+
   /// Build `e`-tags for a thread reply, matching the desktop convention:
   /// - Direct reply to thread head: `["e", id, "", "reply"]`
   /// - Nested reply: `["e", rootId, "", "root"]` + `["e", parentId, "", "reply"]`
diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart
index 5766fab..9c45246 100644
--- a/mobile/test/features/channels/send_message_provider_test.dart
+++ b/mobile/test/features/channels/send_message_provider_test.dart
@@ -6,6 +6,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/send_message_provider.dart';
+import 'package:buzz/shared/profile/user_profile.dart';
 import 'package:buzz/shared/relay/relay.dart';
 
 void main() {
@@ -115,6 +116,203 @@ void main() {
     await result;
   });
 
+  test(
+    'resolves a plain-text @mention never tapped in the picker (non-DM channel)',
+    () async {
+      // Reproduces the bug reported 2026-09-06: the compose bar always
+      // passes a concrete list to `mentionPubkeys` (never null), even when
+      // the user typed "@Name" without selecting it from the picker. That
+      // silently suppressed the text-resolution fallback below.
+      final session = _PendingPublishRelaySession();
+      final signingKey = nostr.Keys.generate().nsec;
+      final sender = nostr.Keys(
+        nostr.Nip19.decode(payload: signingKey).data,
+      ).public;
+      final alice = 'n' * 64;
+      final send = SendMessage(
+        signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
+        fetchMembers: (_) async => [_member(sender), _member(alice)],
+        readUserCache: () => {
+          alice: UserProfile(pubkey: alice, displayName: 'Alice'),
+        },
+        addLocalMessage: (_, _) {},
+        completeLocalMessage: (_, _) {},
+        removeLocalMessage: (_, _) {},
+      );
+
+      final result = send(
+        channelId: _channelId,
+        content: '@Alice hi',
+        // Exactly what the compose bar sends today when nothing was
+        // tapped in the mention picker: empty, not null.
+        mentionPubkeys: const [],
+      );
+      await session.published;
+
+      expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [
+        ['p', alice],
+      ]);
+
+      session.accept();
+      await result;
+    },
+  );
+
+  test(
+    'does not duplicate a pubkey tapped in the picker AND present as text',
+    () async {
+      final session = _PendingPublishRelaySession();
+      final signingKey = nostr.Keys.generate().nsec;
+      final sender = nostr.Keys(
+        nostr.Nip19.decode(payload: signingKey).data,
+      ).public;
+      final alice = 'n' * 64;
+      final send = SendMessage(
+        signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
+        fetchMembers: (_) async => [_member(sender), _member(alice)],
+        readUserCache: () => {
+          alice: UserProfile(pubkey: alice, displayName: 'Alice'),
+        },
+        addLocalMessage: (_, _) {},
+        completeLocalMessage: (_, _) {},
+        removeLocalMessage: (_, _) {},
+      );
+
+      // The picker already resolved "@Alice" to a tap-selected pubkey; the
+      // same name is also present as text. The union must not double-tag.
+      final result = send(
+        channelId: _channelId,
+        content: '@Alice hi',
+        mentionPubkeys: [alice],
+      );
+      await session.published;
+
+      expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [
+        ['p', alice],
+      ]);
+
+      session.accept();
+      await result;
+    },
+  );
+
+  test(
+    'preserves a picker-tapped recipient the text resolver cannot see',
+    () async {
+      final session = _PendingPublishRelaySession();
+      final signingKey = nostr.Keys.generate().nsec;
+      final sender = nostr.Keys(
+        nostr.Nip19.decode(payload: signingKey).data,
+      ).public;
+      final alice = 'n' * 64;
+      final send = SendMessage(
+        signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
+        fetchMembers: (_) async => [_member(sender), _member(alice)],
+        // Empty cache: the text resolver has nothing to match against, so
+        // it would contribute zero pubkeys on its own. The picker's tap
+        // must still make it through untouched.
+        readUserCache: () => const {},
+        addLocalMessage: (_, _) {},
+        completeLocalMessage: (_, _) {},
+        removeLocalMessage: (_, _) {},
+      );
+
+      final result = send(
+        channelId: _channelId,
+        content: '@Alice hi',
+        mentionPubkeys: [alice],
+      );
+      await session.published;
+
+      expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [
+        ['p', alice],
+      ]);
+
+      session.accept();
+      await result;
+    },
+  );
+
+  test(
+    'an ambiguous name shared by two members notifies neither via text '
+    'resolution',
+    () async {
+      final session = _PendingPublishRelaySession();
+      final signingKey = nostr.Keys.generate().nsec;
+      final sender = nostr.Keys(
+        nostr.Nip19.decode(payload: signingKey).data,
+      ).public;
+      final aliceA = 'a' * 64;
+      final aliceB = 'b' * 64;
+      final send = SendMessage(
+        signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
+        fetchMembers: (_) async => [
+          _member(sender),
+          _member(aliceA),
+          _member(aliceB),
+        ],
+        readUserCache: () => {
+          aliceA: UserProfile(pubkey: aliceA, displayName: 'Alice'),
+          aliceB: UserProfile(pubkey: aliceB, displayName: 'Alice'),
+        },
+        addLocalMessage: (_, _) {},
+        completeLocalMessage: (_, _) {},
+        removeLocalMessage: (_, _) {},
+      );
+
+      // Two distinct members named "Alice" in the same channel: resolving
+      // to either one would be a guess, and guessing wrong pings the
+      // wrong agent/person. Neither gets tagged.
+      final result = send(
+        channelId: _channelId,
+        content: '@Alice hi',
+        mentionPubkeys: const [],
+      );
+      await session.published;
+
+      expect(session.event.tags.where((tag) => tag.first == 'p').toList(), []);
+
+      session.accept();
+      await result;
+    },
+  );
+
+  test(
+    'ignores an @name that only appears inside a code span or a quoted '
+    'line',
+    () async {
+      final session = _PendingPublishRelaySession();
+      final signingKey = nostr.Keys.generate().nsec;
+      final sender = nostr.Keys(
+        nostr.Nip19.decode(payload: signingKey).data,
+      ).public;
+      final alice = 'n' * 64;
+      final send = SendMessage(
+        signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
+        fetchMembers: (_) async => [_member(sender), _member(alice)],
+        readUserCache: () => {
+          alice: UserProfile(pubkey: alice, displayName: 'Alice'),
+        },
+        addLocalMessage: (_, _) {},
+        completeLocalMessage: (_, _) {},
+        removeLocalMessage: (_, _) {},
+      );
+
+      final result = send(
+        channelId: _channelId,
+        content:
+            'sample: `@Alice hi` and\n> quoting @Alice from before\nno real mention here',
+        mentionPubkeys: const [],
+      );
+      await session.published;
+
+      expect(session.event.tags.where((tag) => tag.first == 'p').toList(), []);
+
+      session.accept();
+      await result;
+    },
+  );
+
   test('final signed event addresses a human DM recipient', () async {
     final session = _PendingPublishRelaySession();
     final signingKey = nostr.Keys.generate().nsec;

Testing (local, Flutter 3.41.7 — the version pinned by this repo's Hermit config)

  • Added 5 test cases to send_message_provider_test.dart: the reported bug,
    tapped+text duplicate, tapped-only preserved when the cache can't resolve it,
    ambiguous name, and code/quote exclusion.
  • flutter test test/features/channels/send_message_provider_test.dart: 12/12 pass.
    Reverting only the lib change reproduces the bug (1 failure) — confirms the test
    catches it.
  • Isolating each new safeguard (reverting only that part, keeping the union fix) makes
    its own test fail — confirms neither passes by coincidence.
  • flutter test test/features/channels/ test/shared/mentions/: 1006/1014 pass. The 8
    failures are in channel_detail_page_test.dart ("Add members" UI) and reproduce
    identically on main without this change — pre-existing in a sparse-checkout
    environment, unrelated to mentions.
  • flutter analyze on both changed files: no issues.
  • Verified in real use afterward against a live relay: a plain-text mention with this
    fix applied got the correct p tag and the mentioned member replied directly to the
    original event, with no intermediate relay/bot needed to fix it up.

Scope

This covers exactly the case above: a plain-text @Name in a non-DM channel naming a
real, unambiguous channel member. It doesn't touch DM channels, the CLI/SDK Rust
mention parser, or Desktop (which has a related but different mention-admission issue
I'm not covering here).

I don't have push access to this repo, so I can't open this as a PR directly — happy
to if someone can add me as a collaborator, or a maintainer is welcome to apply the
diff above directly.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions