Files
buzz/mobile/lib/features/channels/channel_identity_names_provider.dart
Logan Johnson efe04288ea feat(mobile): add the contextual identity-name resolver (#7894)
🤖
## Summary

When two identities share a display name, Buzz mobile shows the same
label for both. A reader cannot tell a person from an agent, or one
person's agent from another's. For example, a channel can show three
agents and one human all named "murderbot".

This PR adds the naming rules that fix this, without changing any screen
yet. It ports the contextual identity-name contract (v1) from the
desktop app to Dart. Given the identities shown together, the resolver
gives each public key a distinct label:

- A human keeps the plain name. Agents that share it get a qualifier.
- The viewer's own agents are named before other people's agents.
- An agent is qualified by its owner's name first ("Wes's Honey"). A
short public-key suffix is added only if that is still ambiguous
("baxen's murderbot · xa8v").

It also adds two providers that screens will use:

- `channelIdentityNamesProvider` compares names against a channel's
members, plus any referenced non-member such as an old author or an
outside mention.
- `watchIdentityNames` compares a set of identities shown together
outside a channel, and loads their profiles.

The next PRs in this stack connect these to the screens.

Part 1 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910 (default "Fizz" agents from different users collide in
one channel).

### Testing

- The contract's 31 portable fixture cases run against the production
resolver. The fixture file is copied unchanged from the contract, so
reviewers can skip it.
- Unit tests cover owner and suffix qualification, the viewer's own
identities, and invalid public keys.
- Provider tests check that a channel keeps its names while its roster
reloads, that an offline cached bot still counts as an agent, and that
changing relay or account never shows the old roster.
- `flutter analyze` is clean.

No UI change, so there are no screenshots.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-29 12:21:14 -04:00

84 lines
2.9 KiB
Dart

import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/identity_names/identity_names.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import 'channel_management_provider.dart';
import 'channels_provider.dart';
/// Contextual identity labels for one channel, thread, or DM.
///
/// The comparison context is the channel's members; a referenced non-member
/// (historical author, outside mention) is compared against the members plus
/// itself. Watch one label with
/// `ref.watch(channelIdentityNamesProvider(id).select((n) => n.labelFor(pk)))`.
final channelIdentityNamesProvider = Provider.autoDispose
.family<IdentityNames, String>((ref, channelId) {
final sources = ref.watch(identityNameSourcesProvider);
final members =
watchLatestChannelMembers(ref, channelId) ??
(ref.watch(channelsProvider).asData == null
? const <ChannelMember>[]
: ref
.read(channelsProvider.notifier)
.cachedMembersForChannel(channelId));
final names = sources.scope(
[for (final member in members) member.pubkey],
// The roster's own bot roles stay available offline, when the
// relay-backed bot lookup and agent directory are empty.
agentPubkeys: {
...ref.watch(agentMentionPubkeysProvider(channelId)),
for (final member in members)
if (member.isBot) member.pubkey,
},
fallbackNames: {
for (final member in members) member.pubkey: ?member.displayName,
},
);
loadIdentityNameOwners(ref, sources, names.candidates);
return names;
});
/// Watches one identity's contextual label in [channelId].
String watchChannelIdentityLabel(
WidgetRef ref,
String channelId,
String pubkey,
) => ref.watch(
channelIdentityNamesProvider(
channelId,
).select((names) => names.labelFor(pubkey)),
);
/// Watches contextual labels for [pubkeys] in [channelId], keyed by
/// lowercase pubkey. Rebuilds only when one of these labels changes.
Map<String, String> watchChannelIdentityLabels(
WidgetRef ref,
String channelId,
Iterable<String> pubkeys,
) {
final keys = {for (final pubkey in pubkeys) pubkey.toLowerCase()};
if (keys.isEmpty) return const {};
return ref.watch(
channelIdentityNamesProvider(channelId).select(
(names) => _LabelMap({for (final key in keys) key: names.labelFor(key)}),
),
);
}
class _LabelMap extends UnmodifiableMapView<String, String> {
_LabelMap(super.labels);
@override
bool operator ==(Object other) =>
other is Map<String, String> && mapEquals(this, other);
@override
int get hashCode => Object.hashAllUnordered(
entries.map((entry) => Object.hash(entry.key, entry.value)),
);
}