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
179 changes: 168 additions & 11 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,20 +107,59 @@ Future<void> _loadDeepLinkEvents(
}

/// Fetch channel members and preload their profiles into the user cache.
Future<void> _preloadMembers(WidgetRef ref, String channelId) async {
/// Returns whether identity resolution completed successfully.
Future<bool> _preloadMembers(
WidgetRef ref,
String channelId,
List<String> participantPubkeys,
) async {
// Capture references before async gap to avoid using disposed ref.
final notifier = ref.read(userCacheProvider.notifier);
try {
final members = await ref.read(channelMembersProvider(channelId).future);
final pubkeys = members.map((m) => m.pubkey).toList();
final pubkeys = {
...members.map((member) => member.pubkey),
...participantPubkeys,
}.toList();
if (pubkeys.isNotEmpty) {
notifier.preload(pubkeys);
return notifier.preload(pubkeys);
}
return true;
Comment thread
klopez4212 marked this conversation as resolved.
} catch (_) {
// Non-fatal — mentions will just fall back to cache from messages.
// Identity remains unresolved, so agent-only actions stay hidden.
return false;
}
}

Future<void Function()> _subscribeToDmIdentityUpdates(
WidgetRef ref,
List<String> participantPubkeys, {
required VoidCallback onFailure,
}) async {
final session = ref.read(relaySessionProvider.notifier);
return session.subscribe(
NostrFilter(
kinds: const [0, 10100],
authors: participantPubkeys,
limit: 100,
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5),
(event) {
if (event.kind == 0) {
try {
ref.read(userCacheProvider.notifier).cacheProfileEvent(event);
} catch (error) {
debugPrint('[DmIdentity] invalid live profile: $error');
onFailure();
}
} else if (event.kind == 10100) {
ref.invalidate(agentDirectoryProvider);
ref.invalidate(agentOwnersProvider);
}
},
onClosed: (_) => onFailure(),
);
}

int? _channelReadTimestamp({
required Channel channel,
required AsyncValue<List<NostrEvent>> messagesState,
Expand All @@ -146,6 +185,16 @@ int? _channelReadTimestamp({
return dateTimeToUnixSeconds(channel.lastMessageAt);
}

bool _isOneToOneAgentDm(Channel channel, Set<String> agentPubkeys) {
final participants = channel.participantPubkeys
.map((pubkey) => pubkey.trim().toLowerCase())
.where((pubkey) => pubkey.isNotEmpty)
.toSet();
return channel.isDm &&
participants.length == 2 &&
participants.any(agentPubkeys.contains);
}

/// Controls how a hydrated initial thread is added to the navigation stack.
enum InitialThreadRouteBehavior {
/// Keep the channel route beneath the thread.
Expand Down Expand Up @@ -256,10 +305,124 @@ class ChannelDetailPage extends HookConsumerWidget {
channel;
final resolvedChannel =
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
final memberProfilesPreload = useMemoized(
() => _preloadMembers(
ref,
resolvedChannel.id,
resolvedChannel.participantPubkeys,
),
[
resolvedChannel.id,
sessionStatus,
Object.hashAll(resolvedChannel.participantPubkeys),
],
);
Comment thread
klopez4212 marked this conversation as resolved.
final memberProfilesPreloadState = useFuture(memberProfilesPreload);
final showsComposer =
!resolvedChannel.isForum &&
resolvedChannel.isMember &&
!resolvedChannel.isArchived;
final profileOwnedAgentPubkeys = <String>[];
for (final participantPubkey in resolvedChannel.participantPubkeys) {
final normalized = participantPubkey.trim().toLowerCase();
final isProfileOwnedAgent = ref.watch(
userCacheProvider.select(
(cache) => cache[normalized]?.ownerPubkey != null,
),
);
if (isProfileOwnedAgent) profileOwnedAgentPubkeys.add(normalized);
}
final agentDirectoryState = ref.watch(agentDirectoryProvider);
final agentOwnersState = ref.watch(agentOwnersProvider);
Comment thread
klopez4212 marked this conversation as resolved.
final channelBotPubkeysState = ref.watch(
channelBotPubkeysProvider(resolvedChannel.id),
);
Comment thread
klopez4212 marked this conversation as resolved.
final agentPubkeys = agentPubkeysWithChannelBots(
knownAgentPubkeys: agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider),
profileOwnedAgentPubkeys: profileOwnedAgentPubkeys,
),
channelBotPubkeys:
channelBotPubkeysState.asData?.value ?? const <String>{},
);
final participantCount = resolvedChannel.participantPubkeys
.map((pubkey) => pubkey.trim().toLowerCase())
.where((pubkey) => pubkey.isNotEmpty)
.toSet()
.length;
final isOneToOneDm = resolvedChannel.isDm && participantCount == 2;
final identitySubscriptionPubkeys = isOneToOneDm
? (resolvedChannel.participantPubkeys
.map((pubkey) => pubkey.trim().toLowerCase())
.where((pubkey) => pubkey.isNotEmpty)
.toSet()
.toList()
..sort())
: const <String>[];
final identitySubscriptionKey = Object.hashAll(identitySubscriptionPubkeys);
final identitySubscriptionReady = useValueNotifier(false, [
sessionStatus,
resolvedChannel.id,
identitySubscriptionKey,
]);
final isIdentitySubscriptionReady = useValueListenable(
identitySubscriptionReady,
);
useEffect(() {
if (sessionStatus != SessionStatus.connected ||
identitySubscriptionPubkeys.isEmpty) {
return null;
}
var disposed = false;
var subscriptionFailed = false;
void markFailed() {
subscriptionFailed = true;
if (!disposed) identitySubscriptionReady.value = false;
}

void Function()? unsubscribe;
Future.microtask(() async {
try {
final cleanup = await _subscribeToDmIdentityUpdates(
ref,
identitySubscriptionPubkeys,
onFailure: markFailed,
);
if (disposed) {
cleanup();
} else {
unsubscribe = cleanup;
if (!subscriptionFailed) identitySubscriptionReady.value = true;
}
} catch (error) {
if (!disposed) {
debugPrint('[DmIdentity] live subscription failed: $error');
markFailed();
}
}
});
return () {
disposed = true;
unsubscribe?.call();
};
}, [sessionStatus, resolvedChannel.id, identitySubscriptionKey]);
final isAgentIdentityUnresolved =
isOneToOneDm &&
((sessionStatus == SessionStatus.connected &&
!isIdentitySubscriptionReady) ||
agentDirectoryState.isLoading ||
agentDirectoryState.hasError ||
agentOwnersState.isLoading ||
agentOwnersState.hasError ||
channelBotPubkeysState.isLoading ||
channelBotPubkeysState.hasError ||
Comment on lines +417 to +418

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate bot-role subscription failures into the gate

When the kind:39002 live subscription fails to start or is closed terminally after the initial history fetch succeeds, this state remains successful and the Huddle action is enabled for a currently-human peer. Fresh evidence beyond the history-fetch error fix is that _ChannelBotRoleSubscription._subscribe still catches setup errors and supplies no onClosed, so a peer who later gains the bot role is never reclassified while the DM stays open. Surface that subscription readiness/error here, as is already done for the kind:0/10100 subscription, so this safety gate remains fail-closed.

Useful? React with 👍 / 👎.

memberProfilesPreloadState.connectionState !=
ConnectionState.done ||
memberProfilesPreloadState.data != true);
final showsHuddleAction =
showsComposer &&
!isAgentIdentityUnresolved &&
!_isOneToOneAgentDm(resolvedChannel, agentPubkeys);
final messagesNotifier = ref.read(
channelMessagesProvider(channel.id).notifier,
);
Expand Down Expand Up @@ -301,12 +464,6 @@ class ChannelDetailPage extends HookConsumerWidget {
return session.registerVisibleChannel(channel.id);
}, [channel.id]);

// Preload channel member profiles so @mentions resolve correctly.
useEffect(() {
_preloadMembers(ref, channel.id);
return null;
}, [channel.id]);

useEffect(
() {
if (channel.isForum) return null;
Expand Down Expand Up @@ -394,7 +551,7 @@ class ChannelDetailPage extends HookConsumerWidget {
),
actions: resolvedChannel.isDm
? [
if (showsComposer)
if (showsHuddleAction)
_HuddleButton(
channel: resolvedChannel,
events: [
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/shared/mentions/agent_identity_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ class _ChannelBotRoleSubscription extends Notifier<int> {
NostrFilter(
kinds: const [39002],
tags: {
'#h': [channelId],
'#d': [channelId],
},
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000),
(_) {
Expand Down
23 changes: 19 additions & 4 deletions mobile/lib/shared/profile/user_cache_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ import 'user_profile.dart';
class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
final Set<String> _pending = {};
Timer? _batchTimer;
Completer<bool>? _batchCompleter;

@override
Map<String, UserProfile> build() {
ref.watch(relayConfigProvider);
ref.onDispose(() {
_batchTimer?.cancel();
_batchTimer = null;
_batchCompleter?.complete(false);
_batchCompleter = null;
});
return {};
}
Expand All @@ -34,14 +37,19 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
}

/// Preload profiles for a list of pubkeys (e.g. channel members).
void preload(List<String> pubkeys) {
final uncached = pubkeys
/// Returns whether the batch completed successfully.
Future<bool> preload(List<String> pubkeys) {
final normalized = pubkeys.map((pk) => pk.toLowerCase()).toSet();
final alreadyPending = normalized.any(_pending.contains);
final uncached = normalized
.map((pk) => pk.toLowerCase())
.where((pk) => !state.containsKey(pk) && !_pending.contains(pk))
.toList();
if (uncached.isEmpty) return;
if (uncached.isEmpty && !alreadyPending) return Future.value(true);
_pending.addAll(uncached);
final completer = _batchCompleter ??= Completer<bool>();
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
return completer.future;
}

/// Applies a live kind:0 profile event to the cache.
Expand All @@ -57,6 +65,7 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
void _scheduleFetch(String pubkey) {
if (state.containsKey(pubkey) || _pending.contains(pubkey)) return;
_pending.add(pubkey);
_batchCompleter ??= Completer<bool>();
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
}

Expand All @@ -66,7 +75,10 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {

final pubkeys = _pending.toList();
_pending.clear();
final completer = _batchCompleter;
_batchCompleter = null;

var succeeded = false;
try {
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
Expand All @@ -80,8 +92,11 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
}

state = updated;
succeeded = true;
} catch (_) {
// Silently fail — we'll just show pubkeys.
// Silently fail — non-gating callers will just show pubkeys.
} finally {
completer?.complete(succeeded);
}
}

Expand Down
Loading
Loading