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
3 changes: 2 additions & 1 deletion mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ Future<void Function()> _subscribeToDmIdentityUpdates(
}) async {
final session = ref.read(relaySessionProvider.notifier);
var subscriptionStatus = RelaySubscriptionStatus.retrying;
final admission = ref.read(userCacheProvider.notifier).captureAdmission();
var directLookupComplete = false;
final agentPubkeys = <String>{};

Expand All @@ -150,7 +151,7 @@ Future<void Function()> _subscribeToDmIdentityUpdates(
void handleEvent(NostrEvent event) {
if (event.kind == 0) {
try {
ref.read(userCacheProvider.notifier).cacheProfileEvent(event);
admission.add(event);
} catch (error) {
debugPrint('[DmIdentity] invalid live profile: $error');
onFailure();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class _HuddleParticipantProfileUpdates extends Notifier<int> {
final participantPubkeys = ref.watch(
_huddleLogicalParticipantPubkeysProvider(channelId),
);
final admission = ref.read(userCacheProvider.notifier).captureAdmission();
final subscriptionVersion = ++_subscriptionVersion;
_clearSubscription();
ref.onDispose(() {
Expand All @@ -65,7 +66,7 @@ class _HuddleParticipantProfileUpdates extends Notifier<int> {
ref.read(userCacheProvider.notifier).preload(participantPubkeys);
if (relayState.status == SessionStatus.connected) {
Future.microtask(
() => _subscribe(participantPubkeys, subscriptionVersion),
() => _subscribe(participantPubkeys, subscriptionVersion, admission),
);
}
return 0;
Expand All @@ -74,6 +75,7 @@ class _HuddleParticipantProfileUpdates extends Notifier<int> {
Future<void> _subscribe(
List<String> participantPubkeys,
int subscriptionVersion,
ProfileAdmission admission,
) async {
try {
final unsubscribe = await ref
Expand All @@ -86,7 +88,7 @@ class _HuddleParticipantProfileUpdates extends Notifier<int> {
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5),
(event) {
if (!_isCurrent(subscriptionVersion)) return;
ref.read(userCacheProvider.notifier).cacheProfileEvent(event);
admission.add(event);
state++;
},
);
Expand Down
3 changes: 1 addition & 2 deletions mobile/lib/features/channels/channel_directory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,7 @@ Future<Map<String, String>> _resolveDmDisplayNames(
session.fetchHistory(NostrFilters.profilesBatch(dmParticipants.toList())),
);
final displayNames = <String, String>{};
for (final event in profileEvents) {
if (event.kind != 0) continue;
for (final event in latestProfileEvents(profileEvents).values) {
final profile = ProfileData.fromEvent(event);
final label = profile.displayName?.trim().isNotEmpty == true
? profile.displayName!.trim()
Expand Down
35 changes: 5 additions & 30 deletions mobile/lib/features/channels/channel_management_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -305,17 +305,7 @@ List<String> relayMemberPubkeysFromEvents(List<NostrEvent> events) {
/// Converts kind:0 events into a deduplicated, alphabetized people directory.
@visibleForTesting
List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) {
continue;
}
final pubkey = event.pubkey.toLowerCase();
final current = latestByPubkey[pubkey];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pubkey] = event;
}
}
final latestByPubkey = latestProfileEvents(events);

return [
for (final event in latestByPubkey.values)
Expand All @@ -325,7 +315,7 @@ List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
isAgent: verifiedOaOwnerPubkey(event.tags, event.pubkey) != null,
isAgent: verifiedOaOwnerPubkey(event) != null,
),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
Expand Down Expand Up @@ -382,29 +372,14 @@ final relayDirectoryUsersProvider =
NostrFilters.profilesBatch(memberPubkeys),
]);
final profilesByPubkey = {
for (final event in profileEvents)
event.pubkey.toLowerCase(): ProfileData.fromEvent(event),
for (final user in directoryUsersFromProfileEvents(profileEvents))
user.pubkey: user,
};
users =
[
for (final pubkey in memberPubkeys)
if (profilesByPubkey[pubkey] case final profile?)
DirectoryUser(
pubkey: pubkey,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
isAgent:
verifiedOaOwnerPubkey(
profileEvents
.firstWhere(
(event) => event.pubkey.toLowerCase() == pubkey,
)
.tags,
pubkey,
) !=
null,
)
profile
else
DirectoryUser(pubkey: pubkey),
]..sort((a, b) {
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channels_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../shared/community/community_provider.dart';
import '../../shared/push/push_presentation_cache.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme_provider.dart';
import '../../shared/utils/string_utils.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ class ComposeBar extends HookConsumerWidget {
// Preload profiles for channel members, mentionable agents, and their
// owners so @mention suggestions show names ("managed by …" included).
final relayAgents = ref.watch(agentDirectoryProvider).asData?.value;
final agentOwners = ref.watch(agentOwnersProvider).asData?.value;
final owners = ref.watch(agentOwnersProvider);
final agentOwners = owners.asData?.value;
final profilePubkeys = ref.read(userCacheProvider.notifier).profilePubkeys;
final agentMentionLabels = _agentMentionLabels(bindings: mentionMap.value);
final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join(
'\u0000',
Expand Down Expand Up @@ -492,6 +494,8 @@ class ComposeBar extends HookConsumerWidget {
sharedChannelIds: const {},
userCache: userCache,
ownerByAgentPubkey: agentOwners ?? const {},
authoritativeProfilePubkeys: profilePubkeys,
ownerSourceAvailable: !owners.isLoading && !owners.hasError,
),
buildMentionCandidates(
members: membersAsync.asData?.value ?? const [],
Expand All @@ -502,6 +506,8 @@ class ComposeBar extends HookConsumerWidget {
},
userCache: userCache,
ownerByAgentPubkey: agentOwners ?? const {},
authoritativeProfilePubkeys: profilePubkeys,
ownerSourceAvailable: !owners.isLoading && !owners.hasError,
currentPubkey: currentPubkey,
// Reuse ordinary search-result classification, not membership as
// permission. Persisted keys/flags themselves prove no role.
Expand Down
17 changes: 13 additions & 4 deletions mobile/lib/features/channels/mentions/mention_candidates.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,25 @@ List<MentionCandidate> buildMentionCandidates({
required Set<String> sharedChannelIds,
required Map<String, UserProfile> userCache,
required Map<String, String> ownerByAgentPubkey,
Set<String> authoritativeProfilePubkeys = const {},
bool ownerSourceAvailable = true,
List<UserProfile> searchResults = const [],
String? currentPubkey,
}) {
// Display/search profiles can outlive their admitting cache generation.
String? ownerFor(String key) => !ownerSourceAvailable
? null
: authoritativeProfilePubkeys.contains(key)
? userCache[key]?.ownerPubkey
: ownerByAgentPubkey[key];
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 ownerPubkey = ownerFor(pk);
final isAgent = member.isBot || ownerPubkey != null;
candidates.add(
MentionCandidate(
Expand Down Expand Up @@ -104,7 +112,7 @@ List<MentionCandidate> buildMentionCandidates({
avatarUrl: profile?.avatarUrl,
isAgent: true,
isMember: false,
ownerPubkey: ownerByAgentPubkey[pk] ?? profile?.ownerPubkey,
ownerPubkey: ownerFor(pk),
),
);
}
Expand All @@ -113,8 +121,9 @@ List<MentionCandidate> buildMentionCandidates({
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);
final ownerPubkey = ownerFor(pk);
final isAgent =
profile.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const _mentionSearchDebounce = Duration(milliseconds: 250);
/// keystrokes dispose the stale family member so its request never fires.
final mentionUserSearchProvider = FutureProvider.autoDispose
.family<List<UserProfile>, String>((ref, query) async {
ref.watch(relayConfigProvider);
final admission = ref.read(userCacheProvider.notifier).captureAdmission();
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];

Expand All @@ -38,16 +40,11 @@ final mentionUserSearchProvider = FutureProvider.autoDispose
// 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;
}
if (disposed || !admission.isCurrent) return const [];
final latestByPubkey = latestProfileEvents(events);
for (final event in latestByPubkey.values) {
admission.add(event);
}

return [
for (final event in latestByPubkey.values) _profileFromEvent(event),
];
Expand All @@ -61,7 +58,7 @@ UserProfile _profileFromEvent(NostrEvent event) {
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
ownerPubkey: verifiedOaOwnerPubkey(event),
);
}

Expand Down Expand Up @@ -89,7 +86,7 @@ final mentionCandidatesProvider = Provider.family
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final owners = ref.watch(agentOwnersProvider);
final channels = channelsAsync.asData?.value ?? const <Channel>[];
final userCache = ref.watch(userCacheProvider);
final currentPubkey = ref.watch(currentPubkeyProvider);
Expand All @@ -107,7 +104,11 @@ final mentionCandidatesProvider = Provider.family
relayAgents: relayAgents,
sharedChannelIds: sharedChannelIds,
userCache: userCache,
ownerByAgentPubkey: owners,
ownerByAgentPubkey: owners.asData?.value ?? const {},
ownerSourceAvailable: !owners.isLoading && !owners.hasError,
authoritativeProfilePubkeys: ref
.read(userCacheProvider.notifier)
.profilePubkeys,
searchResults: searchResults,
currentPubkey: currentPubkey,
);
Expand Down
12 changes: 2 additions & 10 deletions mobile/lib/features/invites/invite_create_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart';

import '../../shared/community/community_membership_provider.dart';
import '../../shared/utils/string_utils.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/relay/relay.dart';

/// The default lifetime of a newly minted community invite link.
Expand Down Expand Up @@ -301,17 +302,8 @@ final communityInviteActionsProvider = Provider<CommunityInviteActions>((ref) {
List<CommunityInviteDirectoryUser> _directoryUsersFromEvents(
List<NostrEvent> events,
) {
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) continue;
final pubkey = event.pubkey.toLowerCase();
final current = latestByPubkey[pubkey];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pubkey] = event;
}
}
final users = [
for (final event in latestByPubkey.values)
for (final event in latestProfileEvents(events).values)
if (ProfileData.fromEvent(event) case final profile)
CommunityInviteDirectoryUser(
pubkey: profile.pubkey.toLowerCase(),
Expand Down
Loading
Loading