Skip to content

Commit c0b1f6d

Browse files
wesbillmanBrain
andcommitted
mobile: desktop-parity mention autocomplete ranking and info subtitles
Port desktop's mention ranking to the mobile compose bar so both clients filter and order @mention suggestions identically: - mention_ranking.dart mirrors mentionRanking.ts: group order (channel members > people > non-member agents), match quality (exact > prefix > word-exact > word-prefix > pubkey), stable original-order tiebreak. - Candidates now include eligible non-member relay agents from kind:10100 profiles, using desktop's eligibility rule (respond_to anyone + shared channel, or allowlisted), and no longer exclude the current user. - Suggestion rows gain desktop's info subtitle: bot icon + "agent", admin badge, "owned by <name>", and "not in channel". - Owner attribution verifies NIP-OA auth tags (BIP-340 Schnorr) on kind:0 profiles, matching profile_valid_oa_owner_pubkey; the user cache now records the verified ownerPubkey. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
1 parent b652262 commit c0b1f6d

13 files changed

Lines changed: 897 additions & 71 deletions

File tree

mobile/lib/features/channels/compose_bar.dart

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import 'channel.dart';
1515
import 'channel_management_provider.dart';
1616
import 'channels_provider.dart';
1717
import 'emoji_picker.dart';
18+
import 'mentions/mention_candidates.dart';
19+
import 'mentions/mention_candidates_provider.dart';
20+
import 'mentions/mention_ranking.dart';
1821

1922
part 'compose_bar/helpers.dart';
2023
part 'compose_bar/suggestions.dart';
@@ -84,17 +87,31 @@ class ComposeBar extends HookConsumerWidget {
8487
final membersAsync = ref.watch(channelMembersProvider(channelId));
8588
final currentPubkey = ref.watch(currentPubkeyProvider);
8689
final userCache = ref.watch(userCacheProvider);
87-
88-
// Preload profiles for channel members so @mention suggestions show names.
90+
final isDmChannel =
91+
channelsAsync.asData?.value
92+
.any((c) => c.id == channelId && c.isDm) ??
93+
false;
94+
95+
// Preload profiles for channel members, mentionable agents, and their
96+
// owners so @mention suggestions show names ("owned by …" included).
97+
final relayAgents = ref.watch(agentDirectoryProvider).asData?.value;
98+
final agentOwners = ref.watch(agentOwnersProvider).asData?.value;
8999
useEffect(() {
90100
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];
91-
if (memberList.isNotEmpty) {
92-
ref
93-
.read(userCacheProvider.notifier)
94-
.preload(memberList.map((m) => m.pubkey).toList());
101+
final pubkeys = [
102+
...memberList.map((m) => m.pubkey),
103+
...?relayAgents?.map((a) => a.pubkey),
104+
...?agentOwners?.values,
105+
];
106+
if (pubkeys.isNotEmpty) {
107+
ref.read(userCacheProvider.notifier).preload(pubkeys);
95108
}
96109
return null;
97-
}, [membersAsync.asData?.value.length]);
110+
}, [
111+
membersAsync.asData?.value.length,
112+
relayAgents?.length,
113+
agentOwners?.length,
114+
]);
98115

99116
// Typing indicator broadcast — throttled to one event per 3 seconds.
100117
final lastTypingSentMs = useRef(0);
@@ -165,27 +182,39 @@ class ComposeBar extends HookConsumerWidget {
165182
return () => controller.removeListener(listener);
166183
}, [controller]);
167184

168-
// Filter channel members against the query.
169-
final members = membersAsync.asData?.value ?? <ChannelMember>[];
170-
final suggestions = _filterMembers(
171-
members,
172-
mentionQuery.value,
173-
currentPubkey,
174-
userCache,
175-
);
185+
// Ranked mention candidates (desktop-parity ordering + eligibility).
186+
final suggestions = mentionQuery.value == null
187+
? const <MentionCandidate>[]
188+
: ref
189+
.watch(
190+
mentionCandidatesProvider((
191+
channelId: channelId,
192+
query: mentionQuery.value!,
193+
)),
194+
)
195+
.take(_mentionSuggestionLimit)
196+
.toList();
197+
198+
// Resolve owner names for the visible "owned by …" subtitles.
199+
useEffect(() {
200+
final ownerPubkeys = [
201+
for (final s in suggestions) ?s.ownerPubkey,
202+
];
203+
if (ownerPubkeys.isNotEmpty) {
204+
ref.read(userCacheProvider.notifier).preload(ownerPubkeys);
205+
}
206+
return null;
207+
}, [suggestions.length, mentionQuery.value]);
176208

177209
// Filter channels against the query.
178210
final channels = channelsAsync.asData?.value ?? <Channel>[];
179211
final channelSuggestions = filterChannels(channels, channelQuery.value);
180212

181213
// Insert a selected mention into the text field.
182-
void insertMention(ChannelMember member) {
183-
final cached = ref.read(userCacheProvider)[member.pubkey.toLowerCase()];
184-
final name = cached?.displayName?.trim().isNotEmpty == true
185-
? cached!.displayName!.trim()
186-
: '${member.pubkey.substring(0, 8)}\u2026';
214+
void insertMention(MentionCandidate candidate) {
215+
final name = candidate.label;
187216
// Track the resolved pubkey so we can pass it at send time.
188-
mentionMap.value[name] = member.pubkey;
217+
mentionMap.value[name] = candidate.pubkey;
189218

190219
final start = mentionStartIdx.value.clamp(0, controller.text.length);
191220
spliceAndMoveCursor(
@@ -349,6 +378,7 @@ class ComposeBar extends HookConsumerWidget {
349378
suggestions: suggestions,
350379
userCache: userCache,
351380
currentPubkey: currentPubkey,
381+
isDmChannel: isDmChannel,
352382
onSelect: insertMention,
353383
),
354384

mobile/lib/features/channels/compose_bar/helpers.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ part of '../compose_bar.dart';
22

33
const _typingThrottleMs = 3000;
44

5+
/// Cap on ranked mention suggestions shown — matches desktop's
6+
/// `MENTION_SUGGESTION_LIMIT`.
7+
const _mentionSuggestionLimit = 50;
8+
59
/// Walk backward from [cursor] looking for [trigger] (e.g. `@` or `#`) at a
610
/// word boundary. Returns the index of the trigger character, or `null` if none
711
/// is found.

mobile/lib/features/channels/compose_bar/suggestions.dart

Lines changed: 89 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,17 @@
11
part of '../compose_bar.dart';
22

3-
List<ChannelMember> _filterMembers(
4-
List<ChannelMember> members,
5-
String? query,
6-
String? currentPubkey,
7-
Map<String, UserProfile> userCache,
8-
) {
9-
if (query == null) return const [];
10-
final q = query.toLowerCase();
11-
return members
12-
.where(
13-
(m) =>
14-
currentPubkey == null ||
15-
m.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
16-
)
17-
.where((m) {
18-
if (q.isEmpty) return true;
19-
final profile = userCache[m.pubkey.toLowerCase()];
20-
final name = (profile?.displayName ?? m.displayName ?? '')
21-
.toLowerCase();
22-
final firstName = name.split(RegExp(r'\s+')).first;
23-
return name.startsWith(q) ||
24-
firstName.startsWith(q) ||
25-
name.contains(q);
26-
})
27-
.take(6)
28-
.toList();
29-
}
30-
313
class _MentionSuggestions extends StatelessWidget {
32-
final List<ChannelMember> suggestions;
4+
final List<MentionCandidate> suggestions;
335
final Map<String, UserProfile> userCache;
346
final String? currentPubkey;
35-
final void Function(ChannelMember) onSelect;
7+
final bool isDmChannel;
8+
final void Function(MentionCandidate) onSelect;
369

3710
const _MentionSuggestions({
3811
required this.suggestions,
3912
required this.userCache,
4013
required this.currentPubkey,
14+
required this.isDmChannel,
4115
required this.onSelect,
4216
});
4317

@@ -65,17 +39,10 @@ class _MentionSuggestions extends StatelessWidget {
6539
itemCount: suggestions.length,
6640
separatorBuilder: (_, _) => const SizedBox.shrink(),
6741
itemBuilder: (context, index) {
68-
final member = suggestions[index];
69-
final profile = userCache[member.pubkey.toLowerCase()];
70-
final name = profile?.displayName?.trim().isNotEmpty == true
71-
? profile!.displayName!.trim()
72-
: member.labelFor(currentPubkey);
73-
final avatarUrl = profile?.avatarUrl;
74-
final initial =
75-
(profile?.displayName?.trim().isNotEmpty == true
76-
? profile!.displayName!.trim()
77-
: member.pubkey)[0]
78-
.toUpperCase();
42+
final candidate = suggestions[index];
43+
final name = candidate.label;
44+
final avatarUrl =
45+
candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl;
7946

8047
return ListTile(
8148
dense: true,
@@ -88,29 +55,101 @@ class _MentionSuggestions extends StatelessWidget {
8855
: null,
8956
child: avatarUrl == null
9057
? Text(
91-
initial,
58+
name[0].toUpperCase(),
9259
style: context.textTheme.labelSmall?.copyWith(
9360
color: context.colors.onPrimaryContainer,
9461
),
9562
)
9663
: null,
9764
),
9865
title: Text(name, style: context.textTheme.bodyMedium),
99-
trailing: member.isBot
100-
? Icon(
101-
LucideIcons.bot,
102-
size: 14,
103-
color: context.colors.onSurfaceVariant,
104-
)
105-
: null,
106-
onTap: () => onSelect(member),
66+
subtitle: _MentionSuggestionInfo.build(
67+
context,
68+
candidate: candidate,
69+
currentPubkey: currentPubkey,
70+
isDmChannel: isDmChannel,
71+
userCache: userCache,
72+
),
73+
onTap: () => onSelect(candidate),
10774
);
10875
},
10976
),
11077
);
11178
}
11279
}
11380

81+
/// The secondary info line under a mention suggestion — mirrors desktop's
82+
/// `MentionAutocomplete` subtitle: bot icon + "agent" (or an "admin" badge
83+
/// for human admins), then "owned by …" / "not in channel".
84+
abstract final class _MentionSuggestionInfo {
85+
static Widget? build(
86+
BuildContext context, {
87+
required MentionCandidate candidate,
88+
required String? currentPubkey,
89+
required bool isDmChannel,
90+
required Map<String, UserProfile> userCache,
91+
}) {
92+
final ownerLabel = candidate.isAgent
93+
? formatOwnerLabel(candidate.ownerPubkey, currentPubkey, userCache)
94+
: null;
95+
final notInChannel = !isDmChannel && !candidate.isMember;
96+
final isAdmin = !candidate.isAgent && candidate.role == 'admin';
97+
98+
final String? detail;
99+
if (ownerLabel != null && notInChannel) {
100+
detail = 'owned by $ownerLabel \u00b7 not in channel';
101+
} else if (ownerLabel != null) {
102+
detail = 'owned by $ownerLabel';
103+
} else if (notInChannel) {
104+
detail = 'not in channel';
105+
} else {
106+
detail = null;
107+
}
108+
109+
if (!candidate.isAgent && !isAdmin && detail == null) return null;
110+
111+
final style = context.textTheme.labelSmall?.copyWith(
112+
color: context.colors.onSurfaceVariant,
113+
);
114+
115+
return Row(
116+
children: [
117+
if (candidate.isAgent) ...[
118+
Icon(
119+
LucideIcons.bot,
120+
size: 12,
121+
color: context.colors.onSurfaceVariant,
122+
),
123+
const SizedBox(width: Grid.half),
124+
Text('agent', style: style),
125+
] else if (isAdmin)
126+
Container(
127+
padding: const EdgeInsets.symmetric(
128+
horizontal: Grid.xxs,
129+
vertical: 1,
130+
),
131+
decoration: BoxDecoration(
132+
color: context.colors.secondaryContainer,
133+
borderRadius: BorderRadius.circular(Radii.sm),
134+
),
135+
child: Text(
136+
'admin',
137+
style: style?.copyWith(
138+
color: context.colors.onSecondaryContainer,
139+
),
140+
),
141+
),
142+
if (detail != null) ...[
143+
if (candidate.isAgent || isAdmin) const SizedBox(width: Grid.xxs),
144+
Flexible(
145+
child: Text(detail, style: style, overflow: TextOverflow.ellipsis),
146+
),
147+
],
148+
],
149+
);
150+
}
151+
}
152+
114153
@visibleForTesting
115154
List<Channel> filterChannels(List<Channel> channels, String? query) {
116155
if (query == null) return const [];

0 commit comments

Comments
 (0)