Files
buzz/mobile/lib/features/channels/dm_channel_labels.dart
Tom Brow cdc2b129c0 fix(mobile): resolve DM display labels once per sort (#8488)
Home's DM sort resolves participant identity names again for every
comparator call. This work appeared in a captured 82.163 ms UI frame
after foregrounding the full app (Debug/JIT iOS simulator, shared host).
Resolve each lowercase display label once per sort, retaining the same
label ordering and channel-ID tie-breaker. Sort keys are
invocation-local, so later viewer, profile, owner, participant, and
community changes cannot reuse stale labels.

No matching open mobile sorting PR or issue found.

### Validation

- Added synthetic regression coverage for bounded contextual resolution
and ordering parity across identity/name/channel changes, ties, unnamed
DMs, and empty/singleton lists. The 80-DM fixture performs 80 contextual
resolutions instead of the original comparator’s 982; restoring that
comparator makes the regression fail.
- 133 focused tests passed, including existing Home scrolling/reconnect
widget tests and identity-name tests.
- Mobile static analysis passed; 3,697 tests passed with 16 existing
skips, plus four no-gateway configuration tests.
- No new full-app or hardware capture. The existing trace establishes
repeated synchronous work, not its release-build magnitude or the cause
of every scrolling stall.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-20 13:13:15 -07:00

157 lines
5.7 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() : '?';
}
/// Sorts by the visible label, resolving each label only once per invocation.
List<Channel> sortDmChannelsByDisplayLabel(
Iterable<Channel> channels, {
String? currentPubkey,
IdentityNameSources? names,
}) {
// Keep sort keys local: the next call must reflect current viewer, channel
// participants, and community profile/owner names.
final sorted = [
for (final channel in channels)
(
channel: channel,
label: resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
names: names,
).toLowerCase(),
),
];
sorted.sort((left, right) {
final labelCompare = left.label.compareTo(right.label);
if (labelCompare != 0) return labelCompare;
return left.channel.id.compareTo(right.channel.id);
});
return [for (final entry in sorted) entry.channel];
}