Skip to content
Open
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
1 change: 1 addition & 0 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:nostr/nostr.dart' as nostr;

import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/mentions/mention_bindings.dart';
import '../../shared/huddle/huddle_session.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
part of '../compose_bar.dart';

Set<String> _agentMentionLabels({
required Iterable<MentionCandidate> candidates,
}) {
return <String>{
for (final candidate in candidates)
if (candidate.isAgent) candidate.label,
required Map<String, MentionCandidate> bindings,
}) => {
for (final entry in bindings.entries)
if (entry.value.isAgent) entry.key,
};

List<MentionCandidate> _resolveComposerMentions(
String text,
Map<String, MentionCandidate> selected,
List<MentionCandidate> members,
) {
final candidates = <String, List<MentionCandidate>>{
for (final e in selected.entries) e.key.toLowerCase(): [e.value],
};
final selectedNames = candidates.keys.toSet();
for (final member in members) {
final label = member.label.toLowerCase();
if (!selectedNames.contains(label)) (candidates[label] ??= []).add(member);
}
final winners = <String, MentionCandidate>{};
for (final range in mentionOccurrences(text, candidates.keys)) {
final identities = {
for (final c in candidates[range.label]!) c.pubkey.toLowerCase(): c,
};
if (identities.length > 1) {
throw FormatException(
'The mention @${range.label} is ambiguous. Choose a recipient from the mention picker.',
);
}
winners.addAll(identities);
}
return winners.values.toList();
}
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,10 @@ class ComposeBar extends HookConsumerWidget {
// mentions. Used to pass resolved pubkeys directly to onSend and to attach
// selected non-member agents before the message is published.
final mentionMap = useRef(<String, MentionCandidate>{});
useEffect(() {
mentionMap.value.clear();
return null;
}, [draftIdentity, draftKey]);

// Channel autocomplete state ----------------------------------------------
final channelQuery = useState<String?>(null);
Expand All @@ -244,9 +248,7 @@ class ComposeBar extends HookConsumerWidget {
// owners so @mention suggestions show names ("managed by …" included).
final relayAgents = ref.watch(agentDirectoryProvider).asData?.value;
final agentOwners = ref.watch(agentOwnersProvider).asData?.value;
final agentMentionLabels = _agentMentionLabels(
candidates: mentionMap.value.values,
);
final agentMentionLabels = _agentMentionLabels(bindings: mentionMap.value);
final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join(
'\u0000',
);
Expand Down Expand Up @@ -295,6 +297,15 @@ class ComposeBar extends HookConsumerWidget {
final text = editingValue.text;
final sel = editingValue.selection;
final textChanged = text != previousValue.text;
if (textChanged) {
// Formatting/code edits may temporarily hide a binding without
// deleting its literal. Rendering and send extraction remain separate.
final retained = mentionOccurrences(
text.replaceAll('`', ' '),
mentionMap.value.keys,
).map((range) => range.label).toSet();
mentionMap.value.removeWhere((label, _) => !retained.contains(label));
}

// Broadcast typing indicator (throttled).
if (textChanged && text.isNotEmpty) {
Expand Down Expand Up @@ -382,7 +393,10 @@ class ComposeBar extends HookConsumerWidget {

// Insert a selected mention into the text field.
void insertMention(MentionCandidate candidate) {
final name = candidate.label;
final name = selectedMentionLabel(candidate.label, candidate.pubkey, {
for (final entry in mentionMap.value.entries)
entry.key: entry.value.pubkey,
});
// Track the resolved candidate so we can pass its pubkey and prepare
// selected non-member agents at send time.
mentionMap.value[name] = candidate;
Expand Down Expand Up @@ -470,10 +484,27 @@ class ComposeBar extends HookConsumerWidget {
final messenger = ScaffoldMessenger.maybeOf(context);

// Extract pubkeys for mentions present in the final text.
final selectedMentions = <MentionCandidate>[
for (final entry in mentionMap.value.entries)
if (hasMention(text, entry.key)) entry.value,
];
List<MentionCandidate> selectedMentions;
try {
selectedMentions = _resolveComposerMentions(
text,
mentionMap.value,
buildMentionCandidates(
members: channelMembersForAutocomplete(
membersAsync: membersAsync,
sessionStatus: sessionStatus,
cachedMembers: cachedMembers,
),
relayAgents: const [],
sharedChannelIds: const {},
userCache: userCache,
ownerByAgentPubkey: agentOwners ?? const {},
),
);
} on FormatException catch (error) {
messenger?.showSnackBar(SnackBar(content: Text(error.message)));
return;
}
final outgoing = _OutgoingMentions(selectedMentions);
final scan = await _scanNonMemberMentions(
ref,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,16 @@ class _MarkdownEditingController extends TextEditingController {
if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style));

final label = match.group(2)!;
if (RegExp(r'\([0-9a-f]{64}\)').hasMatch(label)) {
spans.add(
TextSpan(
text: '@$label',
style: style.copyWith(color: context.colors.primary),
),
);
offset = match.end;
continue;
}
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
Expand Down
78 changes: 44 additions & 34 deletions mobile/lib/features/channels/message_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:video_player/video_player.dart';

import '../../shared/clipboard_utils.dart';
import '../../shared/mentions/mention_bindings.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/deeplink/pending_deep_link_provider.dart';
import '../../shared/relay/relay.dart';
Expand Down Expand Up @@ -159,6 +160,7 @@ class MessageContent extends HookConsumerWidget {
baseStyle ??
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
final resolvedMentionNames = mentionNames;
final mentionBindings = renderedMentionBindings(content, mentionNames);
final resolvedAgentMentionPubkeys = {
...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
};
Expand Down Expand Up @@ -236,14 +238,15 @@ class MessageContent extends HookConsumerWidget {
mentionBuf.write('`${mentionParts[i]}`');
} else {
var segment = mentionParts[i];
for (final name in resolvedMentionNames.values) {
if (name.contains(' ')) {
final normalizedName = _markdownMentionName(name);
segment = segment.replaceAllMapped(
RegExp('@${RegExp.escape(name)}', caseSensitive: false),
(m) => '@$normalizedName',
);
}
for (final range in mentionOccurrences(
segment,
mentionBindings.keys,
).reversed) {
segment = segment.replaceRange(
range.start,
range.end,
'@${_markdownMentionName(range.label)}',
);
}
mentionBuf.write(segment);
}
Expand Down Expand Up @@ -286,6 +289,14 @@ class MessageContent extends HookConsumerWidget {
inlineComponents: [
_MentionMd(
mentionNames: resolvedMentionNames,
bindings: mentionBindings,
displayLabels: {
for (final range in mentionOccurrences(
content,
mentionBindings.keys,
))
range.label: content.substring(range.start + 1, range.end),
},
agentMentionPubkeys: resolvedAgentMentionPubkeys,
onMentionTap: onMentionTap,
),
Expand Down Expand Up @@ -807,16 +818,20 @@ class _MessageCodeBlock extends HookWidget {
}

class _MentionMd extends InlineMd {
final Map<String, Set<String>> bindings;
final Map<String, String> displayLabels;
final Map<String, String> mentionNames;
final Set<String> agentMentionPubkeys;
final void Function(String pubkey)? onMentionTap;
late final RegExp _exp = _buildPrefixPattern(
prefix: '@',
knownNames: _mentionAliases(mentionNames.values),
knownNames: bindings.keys.map(_markdownMentionName),
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*',
);

_MentionMd({
required this.bindings,
required this.displayLabels,
required this.mentionNames,
required this.agentMentionPubkeys,
this.onMentionTap,
Expand All @@ -837,22 +852,25 @@ class _MentionMd extends InlineMd {
}

final name = raw.substring(1).replaceAll('\u00A0', ' ').toLowerCase();
String? displayName;
String? pubkey;
for (final entry in mentionNames.entries) {
final entryName = entry.value.toLowerCase();
final firstName = entryName.split(RegExp(r'\s+')).first;
if (entryName == name || firstName == name) {
displayName = entry.value;
pubkey = entry.key;
break;
}
final matches = bindings[name] ?? const <String>{};
final pubkey = matches.length == 1 ? matches.single : null;
if (bindings.containsKey(name) && matches.length != 1) {
return TextSpan(text: text, style: config.style);
}
final displayName = name.contains(RegExp(r'\([0-9a-f]{64}\)'))
? displayLabels[name]
: mentionNames[pubkey];

final isAgent =
pubkey != null && agentMentionPubkeys.contains(pubkey.toLowerCase());
final fullLabel = displayName ?? raw.substring(1);
final visibleLabel = fullLabel.replaceAllMapped(
RegExp(r'\(([0-9a-f]{64})\)'),
(m) => '(${m[1]!.substring(0, 8)}…${m[1]!.substring(60)})',
);
final pill = _MentionPill(
label: displayName ?? raw.substring(1),
label: visibleLabel,
semanticsLabel: fullLabel,
isAgent: isAgent,
textStyle: config.style,
);
Expand All @@ -861,19 +879,21 @@ class _MentionMd extends InlineMd {
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: pubkey != null && onMentionTap != null
? GestureDetector(onTap: () => onMentionTap!(pubkey!), child: pill)
? GestureDetector(onTap: () => onMentionTap!(pubkey), child: pill)
: pill,
);
}
}

class _MentionPill extends StatelessWidget {
final String label;
final String? semanticsLabel;
final bool isAgent;
final TextStyle? textStyle;

const _MentionPill({
required this.label,
this.semanticsLabel,
required this.isAgent,
this.textStyle,
});
Expand Down Expand Up @@ -920,23 +940,13 @@ class _MentionPill extends StatelessWidget {
offset: const Offset(0, -Grid.quarter),
child: Text('@', style: style),
),
Text(label, style: style),
Flexible(
child: Text(label, style: style, semanticsLabel: semanticsLabel),
),
],
),
);
}
}

String _markdownMentionName(String name) => name.replaceAll(' ', '\u00A0');

Iterable<String> _mentionAliases(Iterable<String> mentionNames) sync* {
for (final name in mentionNames) {
final trimmed = name.trim();
if (trimmed.isEmpty) continue;
yield _markdownMentionName(trimmed);
final firstName = trimmed.split(RegExp(r'\s+')).first;
if (firstName.isNotEmpty) {
yield firstName;
}
}
}
Loading
Loading