feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,146 @@
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../profile/user_profile.dart';
import '../channel_management_provider.dart';
import 'mention_ranking.dart';
/// Whether a non-member relay agent should be mentionable by the current
/// user. Mirrors desktop's `relayAgentIsSharedWithUser`:
/// - allowlist mode: user must be on the allowlist
/// - anyone mode: agent must share at least one channel with the user
bool agentIsSharedWithUser(
AgentDirectoryEntry agent,
Set<String> sharedChannelIds,
String? currentPubkey,
) {
if (agent.respondTo == 'allowlist' && currentPubkey != null) {
return agent.respondToAllowlist.contains(currentPubkey.toLowerCase());
}
return agent.respondTo == 'anyone' &&
agent.channelIds.any(sharedChannelIds.contains);
}
/// Format the "managed by …" label. Mirrors desktop's `formatOwnerLabel`.
String? formatOwnerLabel(
String? ownerPubkey,
String? currentPubkey,
Map<String, UserProfile> userCache, {
String currentUserLabel = 'you',
}) {
if (ownerPubkey == null) return null;
final owner = ownerPubkey.toLowerCase();
if (currentPubkey != null && owner == currentPubkey.toLowerCase()) {
return currentUserLabel;
}
final profile = userCache[owner];
final name = profile?.displayName?.trim();
if (name != null && name.isNotEmpty) return name;
final handle = profile?.nip05Handle?.trim();
if (handle != null && handle.isNotEmpty) return handle;
return '${ownerPubkey.substring(0, 8)}';
}
/// Assemble the full mention candidate list: channel members first-class,
/// then eligible non-member relay agents, then global user-search results.
/// Mirrors desktop's `useMentions` candidate assembly (minus personas and
/// managed agents, which live in the desktop app's local store; the
/// owner-check on search results below covers their mention-eligibility
/// semantics).
List<MentionCandidate> buildMentionCandidates({
required List<ChannelMember> members,
required List<AgentDirectoryEntry> relayAgents,
required Set<String> sharedChannelIds,
required Map<String, UserProfile> userCache,
required Map<String, String> ownerByAgentPubkey,
List<UserProfile> searchResults = const [],
String? currentPubkey,
}) {
final candidates = <MentionCandidate>[];
final seen = <String>{};
for (final member in members) {
final pk = member.pubkey.toLowerCase();
if (!seen.add(pk)) continue;
final profile = userCache[pk];
final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey;
final isAgent = member.isBot || ownerPubkey != null;
candidates.add(
MentionCandidate(
pubkey: pk,
displayName: profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.displayName,
secondaryLabel: profile?.nip05Handle,
avatarUrl: profile?.avatarUrl,
isAgent: isAgent,
isMember: true,
role: member.role,
ownerPubkey: ownerPubkey,
),
);
}
final directoryPubkeys = <String>{};
final sharedAgentPubkeys = <String>{};
for (final agent in relayAgents) {
directoryPubkeys.add(agent.pubkey);
if (agentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) {
sharedAgentPubkeys.add(agent.pubkey);
}
}
for (final agent in relayAgents) {
final pk = agent.pubkey;
if (seen.contains(pk)) continue;
if (!sharedAgentPubkeys.contains(pk)) continue;
seen.add(pk);
final profile = userCache[pk];
candidates.add(
MentionCandidate(
pubkey: pk,
displayName: profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: agent.displayName,
secondaryLabel: profile?.nip05Handle,
avatarUrl: profile?.avatarUrl,
isAgent: true,
isMember: false,
ownerPubkey: ownerByAgentPubkey[pk] ?? profile?.ownerPubkey,
),
);
}
final currentLower = currentPubkey?.toLowerCase();
for (final profile in searchResults) {
final pk = profile.pubkey.toLowerCase();
if (seen.contains(pk)) continue;
final ownerPubkey = ownerByAgentPubkey[pk] ?? profile.ownerPubkey;
final isAgent = ownerPubkey != null || directoryPubkeys.contains(pk);
if (isAgent) {
// Mirrors desktop's `shouldHideAgentFromMentions` for non-member
// agents: show only when invocable. Invocable = owned by the current
// user (desktop's managed-agent semantics, derived here from the
// verified NIP-OA owner) or shared via the relay agent directory.
final ownedByCurrentUser =
currentLower != null && ownerPubkey?.toLowerCase() == currentLower;
if (!ownedByCurrentUser && !sharedAgentPubkeys.contains(pk)) {
continue;
}
}
seen.add(pk);
candidates.add(
MentionCandidate(
pubkey: pk,
displayName: profile.displayName?.trim().isNotEmpty == true
? profile.displayName!.trim()
: null,
secondaryLabel: profile.nip05Handle,
avatarUrl: profile.avatarUrl,
isAgent: isAgent,
isMember: false,
ownerPubkey: ownerPubkey,
),
);
}
return candidates;
}
@@ -0,0 +1,107 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../shared/crypto/nip_oa.dart';
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../../shared/relay/relay.dart';
import '../../profile/user_cache_provider.dart';
import '../../profile/user_profile.dart';
import '../channel.dart';
import '../channel_management_provider.dart';
import '../channels_provider.dart';
import 'mention_candidates.dart';
import 'mention_ranking.dart';
/// Debounce before a mention query hits the relay search endpoint.
const _mentionSearchDebounce = Duration(milliseconds: 250);
/// Global user search for mention autocomplete — kind:0 prefix search via
/// the relay HTTP bridge. Mirrors desktop's `useInfiniteUserSearchQuery`
/// feeding `useMentions` (source 4: people and agents outside the channel).
///
/// Debounced: the provider waits [_mentionSearchDebounce] before querying;
/// keystrokes dispose the stale family member so its request never fires.
final mentionUserSearchProvider = FutureProvider.autoDispose
.family<List<UserProfile>, String>((ref, query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
var disposed = false;
ref.onDispose(() => disposed = true);
await Future<void>.delayed(_mentionSearchDebounce);
if (disposed) return const [];
final session = ref.read(relaySessionProvider.notifier);
final events = await session.queryRelay([
NostrFilters.searchUsers(trimmed),
]);
// Keep only the latest kind:0 event per pubkey (the bridge does not
// honor the `kinds` filter under search, and may return several
// profile revisions — mirrors desktop's `list_user_search_results`).
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) continue;
final pk = event.pubkey.toLowerCase();
final current = latestByPubkey[pk];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pk] = event;
}
}
return [
for (final event in latestByPubkey.values) _profileFromEvent(event),
];
});
UserProfile _profileFromEvent(NostrEvent event) {
final data = ProfileData.fromEvent(event);
return UserProfile(
pubkey: event.pubkey.toLowerCase(),
displayName: data.displayName,
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
);
}
/// Ranked mention candidates for a channel + query. Channel members first,
/// then non-member relay agents the user can actually reach, then global
/// search results; ordering matches desktop's `rankMentionCandidates`.
final mentionCandidatesProvider = Provider.family
.autoDispose<List<MentionCandidate>, ({String channelId, String query})>((
ref,
args,
) {
final members =
ref.watch(channelMembersProvider(args.channelId)).asData?.value ??
const <ChannelMember>[];
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final channels =
ref.watch(channelsProvider).asData?.value ?? const <Channel>[];
final userCache = ref.watch(userCacheProvider);
final currentPubkey = ref.watch(currentPubkeyProvider);
final searchResults =
ref.watch(mentionUserSearchProvider(args.query)).asData?.value ??
const <UserProfile>[];
final sharedChannelIds = {
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel.id,
};
final candidates = buildMentionCandidates(
members: members,
relayAgents: relayAgents,
sharedChannelIds: sharedChannelIds,
userCache: userCache,
ownerByAgentPubkey: owners,
searchResults: searchResults,
currentPubkey: currentPubkey,
);
return rankMentionCandidates(candidates, args.query);
});
@@ -0,0 +1,104 @@
import 'package:flutter/foundation.dart';
/// A mention autocomplete candidate. Mirrors the desktop's
/// `MentionCandidateForRanking` (desktop/src/features/messages/lib/mentionRanking.ts).
@immutable
class MentionCandidate {
final String pubkey;
final String? displayName;
final String? secondaryLabel;
final String? avatarUrl;
final bool isAgent;
final bool isMember;
final String? role;
final String? ownerPubkey;
const MentionCandidate({
required this.pubkey,
this.displayName,
this.secondaryLabel,
this.avatarUrl,
this.isAgent = false,
this.isMember = false,
this.role,
this.ownerPubkey,
});
String get label {
final name = displayName?.trim();
if (name != null && name.isNotEmpty) return name;
return pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey;
}
}
/// Group rank: channel members, then people, then other agents.
/// Mirrors desktop's `getMentionCandidateGroupRank` (personas are a
/// desktop-only concept; their slot between members and people is unused
/// here so numbering stays aligned).
int _groupRank(MentionCandidate candidate) {
if (candidate.isMember) return 0;
if (!candidate.isAgent) return 2;
return 3;
}
/// Match-quality score for one label. Lower is better; null means no match.
/// Mirrors desktop's `scoreMentionCandidateLabel`.
int? _scoreLabel(String label, String lowerQuery) {
final lower = label.toLowerCase();
if (lower == lowerQuery) return 0;
if (lower.startsWith(lowerQuery)) return 1;
final words = lower.split(RegExp(r'[\s\-_]+')).where((w) => w.isNotEmpty);
if (words.any((word) => word == lowerQuery)) return 2;
if (words.any((word) => word.startsWith(lowerQuery))) return 3;
return null;
}
/// Rank candidates for a mention query. Mirrors desktop's
/// `rankMentionCandidates`: sort by group, then match quality, then the
/// stable original order.
List<MentionCandidate> rankMentionCandidates(
List<MentionCandidate> candidates,
String query,
) {
final lowerQuery = query.toLowerCase();
final ranked = <(MentionCandidate, int, int, int)>[];
for (var order = 0; order < candidates.length; order++) {
final candidate = candidates[order];
final labelScores = [candidate.displayName, candidate.secondaryLabel].map((
value,
) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return _scoreLabel(trimmed, lowerQuery);
}).whereType<int>();
int? score = labelScores.isEmpty
? null
: labelScores.reduce((a, b) => a < b ? a : b);
if (score == null) {
final pubkeyLower = candidate.pubkey.toLowerCase();
if (pubkeyLower.startsWith(lowerQuery)) {
score = 4;
} else if (pubkeyLower.contains(lowerQuery)) {
score = 5;
}
}
if (score == null) continue;
ranked.add((candidate, _groupRank(candidate), score, order));
}
ranked.sort((a, b) {
final group = a.$2.compareTo(b.$2);
if (group != 0) return group;
final score = a.$3.compareTo(b.$3);
if (score != 0) return score;
return a.$4.compareTo(b.$4);
});
return [for (final item in ranked) item.$1];
}