Files
buzz/mobile/lib/features/activity/compose_drafts_provider.dart
Logan Johnson d074576871 fix(mobile): bind same-name mentions to exact selected identities (#7385)
<!-- Draft PR body for #7385 · fix(mobile): bind same-name mentions to
exact selected identities · https://github.com/block/buzz/pull/7385 -->

🤖

## Summary

If two people in a channel share a display name, mobile couldn't tell
them apart in mentions: picking the second could overwrite the first's
selection, a rendered mention linked whichever same-name person matched
first, and a later rename or a shorter name could re-bind the text to
the wrong recipient. This PR binds every mention to the exact selected
identity:

- Each same-name selection keeps its own recipient instead of
overwriting by name; conflicting picks get a qualified label like `Name
(key…)`.
- The longest matching label wins, so a shorter or interior name can
never claim part of a longer one and steal its identity.
- Rendering resolves recipients by the signed identity key from the
event's tags — never from message text alone — so qualified labels stay
correct regardless of tag order and survive later renames; an untagged
ambiguous label blocks shorter mentions instead of silently re-binding.

Ports the landed Desktop exact-recipient behavior (see
`docs/mention-editor.md`).

### Related issue

- Fixes: N/A. No mobile issue; Desktop's landed exact-recipient fixes
are the reference this ports.
- Independent base (`main`). #7387 (child) persists these exact
selections in saved drafts.
- Landing note: branches in this series overlap in the composer — when
rebasing, keep exact/durable mention bindings, the explicit
invite/reference-only choice, the account/visit/revision fences, and
authorization before membership preparation and publication; don't
resolve conflicts by taking either side wholesale.
- Draft — not requesting merge yet; the security advisory run for this
range timed out without results (no verdict).

### Testing

- Regressions cover same-name collisions, prefix/overlap, removal, tag
order, and renames.
- At `acd841354a692243f1cb4c04059baad42f1d2abb`: `just mobile-check` and
full `just mobile-test` pass (2,082 tests). The earlier full `just ci`
receipt linked below is reused only for unchanged non-mobile
code/tooling, not claimed as a rerun at this head.
- Previous-head evidence: `just mobile-check`, the full mobile test
suite, and full local `just ci` all pass — receipts in the [exact-head
evidence
comment](https://github.com/block/buzz/pull/7385#issuecomment-5574635836).
- Verification is widget-test level; no native device or simulator run
is claimed.

To see it: mention two teammates with the same display name — both stay
distinct, the second shows a qualified label, and the rendered message
keeps both correct even after either renames.

### Screenshots

Flutter production-widget test renders — not native-device screenshots
or acceptance captures.

| Scenario | Before | After |
|---|---|---|
| Qualified mention chip for same-name recipients, at 200% text scale
(deliberate stress fixture) | ![Before: the short chip is followed by
the distinguishing key spilling out as raw
text](https://github.com/user-attachments/assets/f1e40acc-24f1-47f7-b2f7-e3ba2348c169)
| ![After: the full qualified label wraps inside the bounded chip,
keeping the @ and robot glyph
visible](https://github.com/user-attachments/assets/b949e44f-e3f6-4c72-98ff-68139b2e1ab3)
|

<details>
<summary>Capture provenance</summary>

Rendered by the Flutter widget engine in a `flutter test` run
(production widgets, production theme; no device or simulator). Before:
this PR's declared base `3c7f288c60d67df78577b237e27c3dfc8831aaa1`.
After: its head `39afd73b0adfde14164f4b10dbd089cb498312b6`.

</details>

Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-09-11 11:19:17 -04:00

187 lines
5.9 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme_provider.dart';
const _draftsPrefsKey = 'compose_drafts_v1';
const _maxDrafts = 50;
/// A locally persisted, unsent composer draft.
///
/// Mobile has no durable relay-backed draft store (desktop keeps drafts
/// locally too), so drafts are device-local by design: the inbox Drafts
/// filter reflects what this device's composer has in flight.
@immutable
class ComposeDraft {
/// `<channelId>` for channel composers, `<channelId>:<threadHeadId>` for
/// thread composers.
final String key;
final String channelId;
final String? threadHeadId;
final String text;
/// Literal picker labels bound to exact keys, never authorization metadata.
/// An empty key/value is a malformed-record tombstone; send must refuse it.
final Map<String, String> mentionKeys;
final int updatedAt; // unix seconds
const ComposeDraft({
required this.key,
required this.channelId,
required this.threadHeadId,
required this.text,
required this.updatedAt,
this.mentionKeys = const {},
});
Map<String, dynamic> toJson() => {
'key': key,
'channel_id': channelId,
if (threadHeadId != null) 'thread_head_id': threadHeadId,
'text': text,
'mention_keys': mentionKeys,
'updated_at': updatedAt,
};
static ComposeDraft? fromJson(Object? raw) {
if (raw is! Map<String, dynamic>) return null;
final key = raw['key'];
final channelId = raw['channel_id'];
final text = raw['text'];
final updatedAt = raw['updated_at'];
if (key is! String || channelId is! String || text is! String) return null;
if (text.trim().isEmpty) return null;
final bindings = raw['mention_keys'];
final mentionKeys = <String, String>{};
if (raw.containsKey('mention_keys')) {
if (bindings is! Map<String, dynamic>) {
mentionKeys[''] = '';
} else {
final labels = <String>{};
for (final entry in bindings.entries) {
if (!labels.add(entry.key.toLowerCase())) mentionKeys[''] = '';
final value = entry.value;
mentionKeys[entry.key] =
entry.key.isNotEmpty &&
value is String &&
RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(value)
? value.toLowerCase()
: '';
}
}
}
return ComposeDraft(
key: key,
channelId: channelId,
threadHeadId: raw['thread_head_id'] is String
? raw['thread_head_id'] as String
: null,
mentionKeys: Map.unmodifiable(mentionKeys),
text: text,
updatedAt: updatedAt is int ? updatedAt : 0,
);
}
}
String composeDraftKey(String channelId, {String? threadHeadId}) =>
threadHeadId == null ? channelId : '$channelId:$threadHeadId';
/// SharedPreferences-backed store of unsent composer drafts, newest first.
///
/// Drafts are plaintext, so persistence is namespaced by community (relay)
/// and account pubkey: switching community or account rebuilds this provider
/// against that identity's own store and can never surface another
/// identity's draft text.
class ComposeDraftsNotifier extends Notifier<List<ComposeDraft>> {
late String _prefsKey;
@override
List<ComposeDraft> build() {
// Rebuild (and re-read the identity-scoped store) whenever the active
// community or the derived account pubkey changes.
final config = ref.watch(relayConfigProvider);
final pubkey = ref.watch(myPubkeyProvider) ?? 'anon';
_prefsKey = '$_draftsPrefsKey:${config.baseUrl}:$pubkey';
final prefs = ref.read(savedPrefsProvider);
final raw = readMigratedPref<String>(
prefs,
canonicalKey: _prefsKey,
legacyKey: '$_draftsPrefsKey:${config.storedOrigin}:$pubkey',
read: prefs.getString,
write: prefs.setString,
);
if (raw == null) return const [];
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return const [];
final drafts = decoded
.map(ComposeDraft.fromJson)
.whereType<ComposeDraft>()
.toList();
drafts.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return drafts;
} catch (_) {
return const [];
}
}
/// Persist [text] for the composer identified by [key]. Empty text removes
/// the draft.
void save({
required String key,
required String channelId,
String? threadHeadId,
required String text,
Map<String, String> mentionKeys = const {},
}) {
if (text.trim().isEmpty) {
remove(key);
return;
}
final existing = state.where((d) => d.key == key).firstOrNull;
if (existing?.text == text &&
mapEquals(existing?.mentionKeys, mentionKeys)) {
return;
}
final draft = ComposeDraft(
key: key,
channelId: channelId,
threadHeadId: threadHeadId,
text: text,
mentionKeys: Map.unmodifiable(mentionKeys),
updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
final next = [draft, ...state.where((d) => d.key != key)];
_persist(next.length > _maxDrafts ? next.sublist(0, _maxDrafts) : next);
}
void remove(String key) {
if (!state.any((d) => d.key == key)) return;
_persist([...state.where((d) => d.key != key)]);
}
/// Read text and selected identities from the same persisted snapshot.
ComposeDraft? draftFor(String key) =>
state.where((d) => d.key == key).firstOrNull;
String? textFor(String key) => draftFor(key)?.text;
void _persist(List<ComposeDraft> drafts) {
state = List.unmodifiable(drafts);
final prefs = ref.read(savedPrefsProvider);
prefs.setString(
_prefsKey,
jsonEncode([for (final d in drafts) d.toJson()]),
);
}
}
final composeDraftsProvider =
NotifierProvider<ComposeDraftsNotifier, List<ComposeDraft>>(
ComposeDraftsNotifier.new,
);