block / block/buzz

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

Open
#7,449 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## 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` — possibly empty, **never
`null`** — passed to `SendMessage.call()`.

`send_message_provider.dart` had:

```dart
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 #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
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> 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 [];
+ final textResolved = await _resolveMentions(content, channelId);
+ final explicitMentions = {
+ ...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> _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 = {};
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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.