@@ -107,17 +107,99 @@ Future<void> _loadDeepLinkEvents(
107107}
108108
109109/// Fetch channel members and preload their profiles into the user cache.
110- Future <void > _preloadMembers (WidgetRef ref, String channelId) async {
110+ /// One-to-one DMs additionally refresh participant profiles for identity gates.
111+ /// Returns whether identity resolution completed successfully.
112+ Future <bool > _preloadMembers (
113+ WidgetRef ref,
114+ String channelId,
115+ List <String > participantPubkeys, {
116+ required bool refreshDmParticipants,
117+ }) async {
111118 // Capture references before async gap to avoid using disposed ref.
112119 final notifier = ref.read (userCacheProvider.notifier);
113120 try {
114121 final members = await ref.read (channelMembersProvider (channelId).future);
115- final pubkeys = members.map ((m) => m.pubkey).toList ();
116- if (pubkeys.isNotEmpty) {
117- notifier.preload (pubkeys);
122+ await notifier.preload (members.map ((member) => member.pubkey).toList ());
123+ if (refreshDmParticipants) {
124+ return notifier.refresh (participantPubkeys);
125+ }
126+ return true ;
127+ } catch (_) {
128+ // Identity remains unresolved, so agent-only actions stay hidden.
129+ return false ;
130+ }
131+ }
132+
133+ Future <void Function ()> _subscribeToDmIdentityUpdates (
134+ WidgetRef ref,
135+ List <String > participantPubkeys, {
136+ required ValueChanged <bool > onReadyChanged,
137+ required ValueChanged <Set <String >> onAgentPubkeysChanged,
138+ required VoidCallback onFailure,
139+ }) async {
140+ final session = ref.read (relaySessionProvider.notifier);
141+ var subscriptionStatus = RelaySubscriptionStatus .retrying;
142+ var directLookupComplete = false ;
143+ final agentPubkeys = < String > {};
144+
145+ void publishAgentPubkeys () {
146+ onAgentPubkeysChanged (Set .unmodifiable (agentPubkeys));
147+ }
148+
149+ void handleEvent (NostrEvent event) {
150+ if (event.kind == 0 ) {
151+ try {
152+ ref.read (userCacheProvider.notifier).cacheProfileEvent (event);
153+ } catch (error) {
154+ debugPrint ('[DmIdentity] invalid live profile: $error ' );
155+ onFailure ();
156+ }
157+ } else if (event.kind == 10100 ) {
158+ agentPubkeys.add (event.pubkey.toLowerCase ());
159+ publishAgentPubkeys ();
160+ ref.invalidate (agentDirectoryProvider);
161+ ref.invalidate (agentOwnersProvider);
118162 }
163+ }
164+
165+ final unsubscribe = await session.subscribeWithStatus (
166+ NostrFilter (
167+ kinds: const [0 , 10100 ],
168+ authors: participantPubkeys,
169+ limit: 100 ,
170+ ).copyWithSince (DateTime .now ().millisecondsSinceEpoch ~ / 1000 - 5 ),
171+ handleEvent,
172+ onClosed: (_) => onFailure (),
173+ onStatusChanged: (status) {
174+ subscriptionStatus = status;
175+ if (status == RelaySubscriptionStatus .retrying) {
176+ onReadyChanged (false );
177+ } else if (directLookupComplete) {
178+ onReadyChanged (true );
179+ }
180+ },
181+ );
182+
183+ try {
184+ final profiles = await session.fetchHistory (
185+ NostrFilter (
186+ kinds: const [10100 ],
187+ authors: participantPubkeys,
188+ limit: participantPubkeys.length,
189+ ),
190+ );
191+ for (final profile in profiles) {
192+ if (profile.kind == 10100 ) {
193+ agentPubkeys.add (profile.pubkey.toLowerCase ());
194+ }
195+ }
196+ publishAgentPubkeys ();
197+ directLookupComplete = true ;
198+ onReadyChanged (subscriptionStatus == RelaySubscriptionStatus .ready);
199+ return unsubscribe;
119200 } catch (_) {
120- // Non-fatal — mentions will just fall back to cache from messages.
201+ unsubscribe ();
202+ rethrow ;
121203 }
122204}
123205
@@ -146,6 +228,16 @@ int? _channelReadTimestamp({
146228 return dateTimeToUnixSeconds (channel.lastMessageAt);
147229}
148230
231+ bool _isOneToOneAgentDm (Channel channel, Set <String > agentPubkeys) {
232+ final participants = channel.participantPubkeys
233+ .map ((pubkey) => pubkey.trim ().toLowerCase ())
234+ .where ((pubkey) => pubkey.isNotEmpty)
235+ .toSet ();
236+ return channel.isDm &&
237+ participants.length == 2 &&
238+ participants.any (agentPubkeys.contains);
239+ }
240+
149241/// Controls how a hydrated initial thread is added to the navigation stack.
150242enum InitialThreadRouteBehavior {
151243 /// Keep the channel route beneath the thread.
@@ -256,10 +348,147 @@ class ChannelDetailPage extends HookConsumerWidget {
256348 channel;
257349 final resolvedChannel =
258350 detailsAsync.whenData (baseChannel.mergeDetails).value ?? baseChannel;
351+ final participantCount = resolvedChannel.participantPubkeys
352+ .map ((pubkey) => pubkey.trim ().toLowerCase ())
353+ .where ((pubkey) => pubkey.isNotEmpty)
354+ .toSet ()
355+ .length;
356+ final isOneToOneDm = resolvedChannel.isDm && participantCount == 2 ;
357+ final memberProfilesPreload = useMemoized (
358+ () => _preloadMembers (
359+ ref,
360+ resolvedChannel.id,
361+ resolvedChannel.participantPubkeys,
362+ refreshDmParticipants: isOneToOneDm,
363+ ),
364+ [
365+ resolvedChannel.id,
366+ sessionStatus,
367+ isOneToOneDm,
368+ Object .hashAll (resolvedChannel.participantPubkeys),
369+ ],
370+ );
371+ final memberProfilesPreloadState = useFuture (memberProfilesPreload);
259372 final showsComposer =
260373 ! resolvedChannel.isForum &&
261374 resolvedChannel.isMember &&
262375 ! resolvedChannel.isArchived;
376+ final profileOwnedAgentPubkeys = < String > [];
377+ for (final participantPubkey in resolvedChannel.participantPubkeys) {
378+ final normalized = participantPubkey.trim ().toLowerCase ();
379+ final isProfileOwnedAgent = ref.watch (
380+ userCacheProvider.select (
381+ (cache) => cache[normalized]? .ownerPubkey != null ,
382+ ),
383+ );
384+ if (isProfileOwnedAgent) profileOwnedAgentPubkeys.add (normalized);
385+ }
386+ final agentDirectoryState = ref.watch (agentDirectoryProvider);
387+ final agentOwnersState = ref.watch (agentOwnersProvider);
388+ final channelMembershipUpdateState = isOneToOneDm
389+ ? ref.watch (channelMembershipUpdateProvider (resolvedChannel.id))
390+ : const ChannelMembershipUpdateState (isReady: true );
391+ final channelBotPubkeysState = ref.watch (
392+ channelBotPubkeysProvider (resolvedChannel.id),
393+ );
394+ final identitySubscriptionPubkeys = isOneToOneDm
395+ ? (resolvedChannel.participantPubkeys
396+ .map ((pubkey) => pubkey.trim ().toLowerCase ())
397+ .where ((pubkey) => pubkey.isNotEmpty)
398+ .toSet ()
399+ .toList ()
400+ ..sort ())
401+ : const < String > [];
402+ final identitySubscriptionKey = Object .hashAll (identitySubscriptionPubkeys);
403+ final identitySubscriptionReady = useValueNotifier (false , [
404+ sessionStatus,
405+ resolvedChannel.id,
406+ identitySubscriptionKey,
407+ ]);
408+ final directlyResolvedAgentPubkeys = useValueNotifier (< String > {}, [
409+ sessionStatus,
410+ resolvedChannel.id,
411+ identitySubscriptionKey,
412+ ]);
413+ final isIdentitySubscriptionReady = useValueListenable (
414+ identitySubscriptionReady,
415+ );
416+ final directAgentPubkeys = useValueListenable (directlyResolvedAgentPubkeys);
417+ final agentPubkeys = agentPubkeysWithChannelBots (
418+ knownAgentPubkeys: agentPubkeysWithProfileOwners (
419+ knownAgentPubkeys: {
420+ ...ref.watch (knownAgentPubkeysProvider),
421+ ...directAgentPubkeys,
422+ },
423+ profileOwnedAgentPubkeys: profileOwnedAgentPubkeys,
424+ ),
425+ channelBotPubkeys:
426+ channelBotPubkeysState.asData? .value ?? const < String > {},
427+ );
428+ useEffect (() {
429+ if (sessionStatus != SessionStatus .connected ||
430+ identitySubscriptionPubkeys.isEmpty) {
431+ return null ;
432+ }
433+ var disposed = false ;
434+ var subscriptionFailed = false ;
435+ void markFailed () {
436+ subscriptionFailed = true ;
437+ if (! disposed) identitySubscriptionReady.value = false ;
438+ }
439+
440+ void Function ()? unsubscribe;
441+ Future .microtask (() async {
442+ try {
443+ final cleanup = await _subscribeToDmIdentityUpdates (
444+ ref,
445+ identitySubscriptionPubkeys,
446+ onReadyChanged: (isReady) {
447+ if (! disposed && ! subscriptionFailed) {
448+ identitySubscriptionReady.value = isReady;
449+ }
450+ },
451+ onAgentPubkeysChanged: (pubkeys) {
452+ if (! disposed) directlyResolvedAgentPubkeys.value = pubkeys;
453+ },
454+ onFailure: markFailed,
455+ );
456+ if (disposed) {
457+ cleanup ();
458+ } else {
459+ unsubscribe = cleanup;
460+ }
461+ } catch (error) {
462+ if (! disposed) {
463+ debugPrint ('[DmIdentity] live subscription failed: $error ' );
464+ markFailed ();
465+ }
466+ }
467+ });
468+ return () {
469+ disposed = true ;
470+ unsubscribe? .call ();
471+ };
472+ }, [sessionStatus, resolvedChannel.id, identitySubscriptionKey]);
473+ final isAgentIdentityUnresolved =
474+ isOneToOneDm &&
475+ (sessionStatus != SessionStatus .connected ||
476+ ! isIdentitySubscriptionReady ||
477+ agentDirectoryState.isLoading ||
478+ agentDirectoryState.hasError ||
479+ agentOwnersState.isLoading ||
480+ agentOwnersState.hasError ||
481+ ! channelMembershipUpdateState.isReady ||
482+ channelMembershipUpdateState.error != null ||
483+ channelBotPubkeysState.isLoading ||
484+ channelBotPubkeysState.hasError ||
485+ memberProfilesPreloadState.connectionState !=
486+ ConnectionState .done ||
487+ memberProfilesPreloadState.data != true );
488+ final showsHuddleAction =
489+ showsComposer &&
490+ ! isAgentIdentityUnresolved &&
491+ ! _isOneToOneAgentDm (resolvedChannel, agentPubkeys);
263492 final messagesNotifier = ref.read (
264493 channelMessagesProvider (channel.id).notifier,
265494 );
@@ -301,12 +530,6 @@ class ChannelDetailPage extends HookConsumerWidget {
301530 return session.registerVisibleChannel (channel.id);
302531 }, [channel.id]);
303532
304- // Preload channel member profiles so @mentions resolve correctly.
305- useEffect (() {
306- _preloadMembers (ref, channel.id);
307- return null ;
308- }, [channel.id]);
309-
310533 useEffect (
311534 () {
312535 if (channel.isForum) return null ;
@@ -394,7 +617,7 @@ class ChannelDetailPage extends HookConsumerWidget {
394617 ),
395618 actions: resolvedChannel.isDm
396619 ? [
397- if (showsComposer )
620+ if (showsHuddleAction )
398621 _HuddleButton (
399622 channel: resolvedChannel,
400623 events: [
0 commit comments