fix(mobile): username display, huddle filtering, and channel list stability (#489)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-05 18:35:17 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3d585dc044
commit 6dbb66d858
4 changed files with 60 additions and 12 deletions
@@ -56,7 +56,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Future<List<Channel>> _fetch({bool subscribeLive = false}) async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) return const [];
if (myPk == null) throw StateError('No signing identity available');
final session = ref.read(relaySessionProvider.notifier);
@@ -126,9 +126,14 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final channels = <Channel>[];
for (final event in dedupedMetas) {
channels.add(
_channelFromMeta(event, isMember: true, displayNames: displayNames),
final channel = _channelFromMeta(
event,
isMember: true,
displayNames: displayNames,
);
if (!channel.isEphemeral) {
channels.add(channel);
}
}
channels.sort((left, right) {
@@ -180,6 +185,8 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
participants: participants,
participantPubkeys: data.participantPubkeys,
isMember: isMember,
ttlSeconds: data.ttlSeconds,
ttlDeadline: data.ttlDeadline,
);
}
+28 -7
View File
@@ -76,6 +76,17 @@ class ComposeBar extends HookConsumerWidget {
final currentPubkey = ref.watch(currentPubkeyProvider);
final userCache = ref.watch(userCacheProvider);
// Preload profiles for channel members so @mention suggestions show names.
useEffect(() {
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];
if (memberList.isNotEmpty) {
ref
.read(userCacheProvider.notifier)
.preload(memberList.map((m) => m.pubkey).toList());
}
return null;
}, [membersAsync.asData?.value.length]);
// Typing indicator broadcast — throttled to one event per 3 seconds.
final lastTypingSentMs = useRef(0);
@@ -149,6 +160,7 @@ class ComposeBar extends HookConsumerWidget {
members,
mentionQuery.value,
currentPubkey,
userCache,
);
// Filter channels against the query.
@@ -157,9 +169,10 @@ class ComposeBar extends HookConsumerWidget {
// Insert a selected mention into the text field.
void insertMention(ChannelMember member) {
final name = member.displayName?.trim().isNotEmpty == true
? member.displayName!.trim()
: member.pubkey.substring(0, 8);
final cached = ref.read(userCacheProvider)[member.pubkey.toLowerCase()];
final name = cached?.displayName?.trim().isNotEmpty == true
? cached!.displayName!.trim()
: '${member.pubkey.substring(0, 8)}\u2026';
// Track the resolved pubkey so we can pass it at send time.
mentionMap.value[name] = member.pubkey;
@@ -616,6 +629,7 @@ List<ChannelMember> _filterMembers(
List<ChannelMember> members,
String? query,
String? currentPubkey,
Map<String, UserProfile> userCache,
) {
if (query == null) return const [];
final q = query.toLowerCase();
@@ -627,7 +641,9 @@ List<ChannelMember> _filterMembers(
)
.where((m) {
if (q.isEmpty) return true;
final name = (m.displayName ?? '').toLowerCase();
final profile = userCache[m.pubkey.toLowerCase()];
final name = (profile?.displayName ?? m.displayName ?? '')
.toLowerCase();
final firstName = name.split(RegExp(r'\s+')).first;
return name.startsWith(q) ||
firstName.startsWith(q) ||
@@ -675,11 +691,16 @@ class _MentionSuggestions extends StatelessWidget {
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final member = suggestions[index];
final name = member.labelFor(currentPubkey);
final profile = userCache[member.pubkey.toLowerCase()];
final name = profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.labelFor(currentPubkey);
final avatarUrl = profile?.avatarUrl;
final initial = (member.displayName ?? member.pubkey)[0]
.toUpperCase();
final initial =
(profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.pubkey)[0]
.toUpperCase();
return ListTile(
dense: true,
@@ -222,7 +222,11 @@ class _MemberTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final label = member.labelFor(currentPubkey);
final label = isSelf
? 'You'
: (profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.labelFor(currentPubkey));
final initial = label.substring(0, 1).toUpperCase();
final showManagementActions = canManage && !isSelf && !member.isOwner;
final showMenu = showManagementActions || onViewActivity != null;
@@ -288,7 +292,11 @@ class _MemberTile extends ConsumerWidget {
WidgetRef ref, {
required bool showManagementActions,
}) {
final label = member.labelFor(currentPubkey);
final label = isSelf
? 'You'
: (profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.labelFor(currentPubkey));
final canChangeRole = showManagementActions && !member.isBot;
showModalBottomSheet<void>(
context: context,
+12
View File
@@ -246,6 +246,8 @@ class ChannelData {
final String description;
final String? topic;
final List<String> participantPubkeys;
final int? ttlSeconds;
final DateTime? ttlDeadline;
const ChannelData({
required this.id,
@@ -255,6 +257,8 @@ class ChannelData {
required this.description,
this.topic,
this.participantPubkeys = const [],
this.ttlSeconds,
this.ttlDeadline,
});
factory ChannelData.fromEvent(NostrEvent event) {
@@ -280,6 +284,12 @@ class ChannelData {
for (final t in event.tags)
if (t.length >= 2 && t[0] == 'p') t[1],
];
final ttlRaw = event.getTagValue('ttl');
final ttlSeconds = ttlRaw != null ? int.tryParse(ttlRaw) : null;
final ttlDeadlineRaw = event.getTagValue('ttl_deadline');
final ttlDeadline = ttlDeadlineRaw != null
? DateTime.tryParse(ttlDeadlineRaw)
: null;
return ChannelData(
id: id,
name: name,
@@ -288,6 +298,8 @@ class ChannelData {
description: description,
topic: topic,
participantPubkeys: participants,
ttlSeconds: ttlSeconds,
ttlDeadline: ttlDeadline,
);
}
}