Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 55 additions & 25 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import 'channel.dart';
import 'channel_management_provider.dart';
import 'channels_provider.dart';
import 'emoji_picker.dart';
import 'mentions/mention_candidates.dart';
import 'mentions/mention_candidates_provider.dart';
import 'mentions/mention_ranking.dart';

part 'compose_bar/helpers.dart';
part 'compose_bar/suggestions.dart';
Expand Down Expand Up @@ -84,17 +87,33 @@ class ComposeBar extends HookConsumerWidget {
final membersAsync = ref.watch(channelMembersProvider(channelId));
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]);
final isDmChannel =
channelsAsync.asData?.value.any((c) => c.id == channelId && c.isDm) ??
false;

// Preload profiles for channel members, mentionable agents, and their
// owners so @mention suggestions show names ("owned by …" included).
final relayAgents = ref.watch(agentDirectoryProvider).asData?.value;
final agentOwners = ref.watch(agentOwnersProvider).asData?.value;
useEffect(
() {
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];
final pubkeys = [
...memberList.map((m) => m.pubkey),
...?relayAgents?.map((a) => a.pubkey),
...?agentOwners?.values,
];
if (pubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(pubkeys);
}
return null;
},
[
membersAsync.asData?.value.length,
relayAgents?.length,
agentOwners?.length,
],
);

// Typing indicator broadcast — throttled to one event per 3 seconds.
final lastTypingSentMs = useRef(0);
Expand Down Expand Up @@ -165,27 +184,37 @@ class ComposeBar extends HookConsumerWidget {
return () => controller.removeListener(listener);
}, [controller]);

// Filter channel members against the query.
final members = membersAsync.asData?.value ?? <ChannelMember>[];
final suggestions = _filterMembers(
members,
mentionQuery.value,
currentPubkey,
userCache,
);
// Ranked mention candidates (desktop-parity ordering + eligibility).
final suggestions = mentionQuery.value == null
? const <MentionCandidate>[]
: ref
.watch(
mentionCandidatesProvider((
channelId: channelId,
query: mentionQuery.value!,
)),
)
.take(_mentionSuggestionLimit)
.toList();

// Resolve owner names for the visible "owned by …" subtitles.
useEffect(() {
final ownerPubkeys = [for (final s in suggestions) ?s.ownerPubkey];
if (ownerPubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(ownerPubkeys);
}
return null;
}, [suggestions.length, mentionQuery.value]);

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

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

final start = mentionStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
Expand Down Expand Up @@ -349,6 +378,7 @@ class ComposeBar extends HookConsumerWidget {
suggestions: suggestions,
userCache: userCache,
currentPubkey: currentPubkey,
isDmChannel: isDmChannel,
onSelect: insertMention,
),

Expand Down
4 changes: 4 additions & 0 deletions mobile/lib/features/channels/compose_bar/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ part of '../compose_bar.dart';

const _typingThrottleMs = 3000;

/// Cap on ranked mention suggestions shown — matches desktop's
/// `MENTION_SUGGESTION_LIMIT`.
const _mentionSuggestionLimit = 50;

/// Walk backward from [cursor] looking for [trigger] (e.g. `@` or `#`) at a
/// word boundary. Returns the index of the trigger character, or `null` if none
/// is found.
Expand Down
139 changes: 89 additions & 50 deletions mobile/lib/features/channels/compose_bar/suggestions.dart
Original file line number Diff line number Diff line change
@@ -1,43 +1,17 @@
part of '../compose_bar.dart';

List<ChannelMember> _filterMembers(
List<ChannelMember> members,
String? query,
String? currentPubkey,
Map<String, UserProfile> userCache,
) {
if (query == null) return const [];
final q = query.toLowerCase();
return members
.where(
(m) =>
currentPubkey == null ||
m.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
)
.where((m) {
if (q.isEmpty) return true;
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) ||
name.contains(q);
})
.take(6)
.toList();
}

class _MentionSuggestions extends StatelessWidget {
final List<ChannelMember> suggestions;
final List<MentionCandidate> suggestions;
final Map<String, UserProfile> userCache;
final String? currentPubkey;
final void Function(ChannelMember) onSelect;
final bool isDmChannel;
final void Function(MentionCandidate) onSelect;

const _MentionSuggestions({
required this.suggestions,
required this.userCache,
required this.currentPubkey,
required this.isDmChannel,
required this.onSelect,
});

Expand Down Expand Up @@ -65,17 +39,10 @@ class _MentionSuggestions extends StatelessWidget {
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final member = suggestions[index];
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 =
(profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.pubkey)[0]
.toUpperCase();
final candidate = suggestions[index];
final name = candidate.label;
final avatarUrl =
candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl;

return ListTile(
dense: true,
Expand All @@ -88,29 +55,101 @@ class _MentionSuggestions extends StatelessWidget {
: null,
child: avatarUrl == null
? Text(
initial,
name[0].toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
),
)
: null,
),
title: Text(name, style: context.textTheme.bodyMedium),
trailing: member.isBot
? Icon(
LucideIcons.bot,
size: 14,
color: context.colors.onSurfaceVariant,
)
: null,
onTap: () => onSelect(member),
subtitle: _MentionSuggestionInfo.build(
context,
candidate: candidate,
currentPubkey: currentPubkey,
isDmChannel: isDmChannel,
userCache: userCache,
),
onTap: () => onSelect(candidate),
);
},
),
);
}
}

/// The secondary info line under a mention suggestion — mirrors desktop's
/// `MentionAutocomplete` subtitle: bot icon + "agent" (or an "admin" badge
/// for human admins), then "owned by …" / "not in channel".
abstract final class _MentionSuggestionInfo {
static Widget? build(
BuildContext context, {
required MentionCandidate candidate,
required String? currentPubkey,
required bool isDmChannel,
required Map<String, UserProfile> userCache,
}) {
final ownerLabel = candidate.isAgent
? formatOwnerLabel(candidate.ownerPubkey, currentPubkey, userCache)
: null;
final notInChannel = !isDmChannel && !candidate.isMember;
final isAdmin = !candidate.isAgent && candidate.role == 'admin';

final String? detail;
if (ownerLabel != null && notInChannel) {
detail = 'owned by $ownerLabel \u00b7 not in channel';
} else if (ownerLabel != null) {
detail = 'owned by $ownerLabel';
} else if (notInChannel) {
detail = 'not in channel';
} else {
detail = null;
}

if (!candidate.isAgent && !isAdmin && detail == null) return null;

final style = context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
);

return Row(
children: [
if (candidate.isAgent) ...[
Icon(
LucideIcons.bot,
size: 12,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Text('agent', style: style),
] else if (isAdmin)
Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: 1,
),
decoration: BoxDecoration(
color: context.colors.secondaryContainer,
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Text(
'admin',
style: style?.copyWith(
color: context.colors.onSecondaryContainer,
),
),
),
if (detail != null) ...[
if (candidate.isAgent || isAdmin) const SizedBox(width: Grid.xxs),
Flexible(
child: Text(detail, style: style, overflow: TextOverflow.ellipsis),
),
],
],
);
}
}

@visibleForTesting
List<Channel> filterChannels(List<Channel> channels, String? query) {
if (query == null) return const [];
Expand Down
Loading
Loading