Skip to content

Commit 822c5ab

Browse files
klopez4212Princess Donut
andauthored
Hide Huddles in mobile agent DMs (#6676)
## Summary - hide the mobile Huddle action in one-to-one agent DMs - preserve Huddles for human DMs and group DMs ## Testing - just mobile-check - flutter test (1,662 tests) --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
1 parent a8e1c66 commit 822c5ab

12 files changed

Lines changed: 2045 additions & 161 deletions

mobile/lib/features/channels/channel_detail_page.dart

Lines changed: 235 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
150242
enum 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: [

mobile/lib/features/channels/channel_management_provider.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,11 @@ final channelDetailsProvider = FutureProvider.family<ChannelDetails, String>((
488488
/// Channel members from kind:39002 NIP-29 members event.
489489
final channelMembersProvider = FutureProvider.autoDispose
490490
.family<List<ChannelMember>, String>((ref, channelId) async {
491-
ref.watch(channelMembershipUpdateProvider(channelId));
491+
ref.watch(
492+
channelMembershipUpdateProvider(
493+
channelId,
494+
).select((update) => update.version),
495+
);
492496
final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl;
493497
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
494498
final snapshotCache = ref.read(_channelMembersSnapshotCacheProvider);

0 commit comments

Comments
 (0)