mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
🤖 ## Summary This PR finishes the change on the remaining mobile screens. After it, every place that names a person or agent uses a contextual name: - The channel list, DM labels, and the new-DM picker - The channel header and details, and the members and add-members sheets - Huddle avatars, spotlight, and overlay - The Activity inbox - Search results for people and messages - Pulse notes, agent cards, and reply previews Screens that belong to a channel compare names against its members. Search, Pulse, and the new-DM picker have no channel, so they compare the identities shown together. In the example below, Search showed four results all named "murderbot". Now the human keeps the plain name, and baxen's three agents are labeled by owner and key suffix. Part 3 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists, Search, Pulse). ### Related issue Related to #2910. The desktop search change in #6483 addresses the same problem in a different app. ### Testing - Widget tests cover the Activity inbox, Search, and Pulse notes with colliding names. - `flutter analyze` is clean, and the full `flutter test` suite passes. - Checked on an Android emulator against the live relay by searching for "murderbot". The after image also shows "classy-murderbot", a new account that was created between the two captures. | Before | After | | --- | --- | | <img width="300" alt="Before: Search shows four people all named murderbot" src="https://github.com/user-attachments/assets/21bbcc52-fea8-4b31-ab8c-ff3ec9b32a39" /> | <img width="300" alt="After: three results show as baxen's murderbot with a key suffix, and the human shows as murderbot" src="https://github.com/user-attachments/assets/d59584d9-4a35-46da-b39a-83addfad3cd2" /> | --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
156 lines
5.6 KiB
Dart
156 lines
5.6 KiB
Dart
import '../../shared/identity_names/identity_names.dart';
|
|
import '../../shared/utils/string_utils.dart';
|
|
import 'channel.dart';
|
|
|
|
const int _dmParticipantPreviewLimit = 3;
|
|
|
|
bool isGenericDmChannelName(String name) {
|
|
final normalized = name.trim().toLowerCase();
|
|
if (normalized.isEmpty ||
|
|
normalized == 'dm' ||
|
|
normalized == 'direct message' ||
|
|
normalized == 'direct messages') {
|
|
return true;
|
|
}
|
|
return RegExp(r'^group dm\s*(\(\d+\))?$').hasMatch(normalized);
|
|
}
|
|
|
|
String formatDmParticipantDisplayName(List<String> displayNames) {
|
|
final visible = displayNames.take(_dmParticipantPreviewLimit).toList();
|
|
final hiddenCount = displayNames.length - visible.length;
|
|
return hiddenCount > 0
|
|
? [...visible, '+$hiddenCount more'].join(', ')
|
|
: visible.join(', ');
|
|
}
|
|
|
|
/// Contextual participant labels for a DM: its participants are the whole
|
|
/// comparison context, and relay participant names are fallbacks.
|
|
IdentityNames dmParticipantNames(Channel channel, IdentityNameSources names) =>
|
|
names.scope(
|
|
channel.participantPubkeys,
|
|
fallbackNames: {
|
|
for (var index = 0; index < channel.participantPubkeys.length; index++)
|
|
if (index < channel.participants.length)
|
|
channel.participantPubkeys[index]: channel.participants[index],
|
|
},
|
|
);
|
|
|
|
/// The DM's display label. With [names], participant labels follow the
|
|
/// contextual identity-name policy, so namesakes stay distinguishable.
|
|
String resolveDmChannelDisplayLabel(
|
|
Channel channel, {
|
|
String? currentPubkey,
|
|
IdentityNameSources? names,
|
|
}) {
|
|
if (!channel.isDm || !isGenericDmChannelName(channel.name)) {
|
|
return channel.name;
|
|
}
|
|
|
|
final normalizedCurrent = currentPubkey?.toLowerCase();
|
|
final participantNames = names == null
|
|
? null
|
|
: dmParticipantNames(channel, names);
|
|
final participants = <({String label, String? pubkey})>[
|
|
for (var index = 0; index < channel.participantPubkeys.length; index++)
|
|
(
|
|
label:
|
|
participantNames?.labelFor(channel.participantPubkeys[index]) ??
|
|
(index < channel.participants.length
|
|
? channel.participants[index]
|
|
: shortPubkey(channel.participantPubkeys[index])),
|
|
pubkey: channel.participantPubkeys[index].toLowerCase(),
|
|
),
|
|
];
|
|
|
|
final displayParticipants = normalizedCurrent == null
|
|
? participants
|
|
: participants
|
|
.where((participant) => participant.pubkey != normalizedCurrent)
|
|
.toList();
|
|
final labels = <String>{
|
|
for (final participant
|
|
in displayParticipants.isNotEmpty ? displayParticipants : participants)
|
|
participant.label,
|
|
}.toList();
|
|
|
|
return labels.isNotEmpty
|
|
? formatDmParticipantDisplayName(labels)
|
|
: channel.name;
|
|
}
|
|
|
|
/// Avatar initial for the DM's visible counterpart.
|
|
///
|
|
/// Mirrors [resolveDmChannelDisplayLabel]'s participant selection: the
|
|
/// first participant that is not [currentPubkey], since member order does
|
|
/// not guarantee the counterpart is listed first — otherwise the avatar
|
|
/// could identify the current user while the label beside it identifies
|
|
/// the counterpart. When every participant is the current user (a
|
|
/// self-DM), the label names the current user too, so the avatar keys off
|
|
/// that same first participant instead of its label's first character.
|
|
///
|
|
/// A resolved display name keeps its name-derived initial. A label that
|
|
/// fell back to the compact npub form of the participant's key is keyed to
|
|
/// the hex public key instead — the npub form starts with `npub1`, so every
|
|
/// unnamed participant would otherwise render `N`.
|
|
String dmAvatarInitial(Channel channel, {String? currentPubkey}) {
|
|
final normalizedCurrent = currentPubkey?.toLowerCase();
|
|
var index = 0;
|
|
while (normalizedCurrent != null &&
|
|
index < channel.participantPubkeys.length &&
|
|
channel.participantPubkeys[index].toLowerCase() == normalizedCurrent) {
|
|
index++;
|
|
}
|
|
|
|
if (index >= channel.participantPubkeys.length) {
|
|
// No participant pubkeys (labels only): fall back to the first
|
|
// participant label, like the channel label does when the non-self
|
|
// list is empty.
|
|
if (channel.participantPubkeys.isEmpty) {
|
|
final label = channel.participants.isNotEmpty
|
|
? channel.participants.first
|
|
: '';
|
|
return label.isNotEmpty ? label[0].toUpperCase() : '?';
|
|
}
|
|
// Keys exist but every participant is the current user (a self-DM):
|
|
// the label names the current user, so key the avatar to that same
|
|
// first participant with the shared provenance rule below — the hex
|
|
// key for a compact npub label, the authored name otherwise.
|
|
index = 0;
|
|
}
|
|
|
|
final pubkey = channel.participantPubkeys[index];
|
|
final label = index < channel.participants.length
|
|
? channel.participants[index]
|
|
: shortPubkey(pubkey);
|
|
if (pubkey.isNotEmpty && label == shortPubkey(pubkey)) {
|
|
return pubkey[0].toUpperCase();
|
|
}
|
|
return label.isNotEmpty ? label[0].toUpperCase() : '?';
|
|
}
|
|
|
|
List<Channel> sortDmChannelsByDisplayLabel(
|
|
Iterable<Channel> channels, {
|
|
String? currentPubkey,
|
|
IdentityNameSources? names,
|
|
}) {
|
|
final sorted = channels.toList();
|
|
sorted.sort((left, right) {
|
|
final leftLabel = resolveDmChannelDisplayLabel(
|
|
left,
|
|
currentPubkey: currentPubkey,
|
|
names: names,
|
|
);
|
|
final rightLabel = resolveDmChannelDisplayLabel(
|
|
right,
|
|
currentPubkey: currentPubkey,
|
|
names: names,
|
|
);
|
|
final labelCompare = leftLabel.toLowerCase().compareTo(
|
|
rightLabel.toLowerCase(),
|
|
);
|
|
if (labelCompare != 0) return labelCompare;
|
|
return left.id.compareTo(right.id);
|
|
});
|
|
return sorted;
|
|
}
|