Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
162 changes: 152 additions & 10 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,20 +107,52 @@ 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) 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();
if (pubkeys.isNotEmpty) {
notifier.preload(pubkeys);
return notifier.preload(pubkeys);
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
}
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(
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
NostrFilter(
kinds: const [0, 10100],
authors: participantPubkeys,
limit: 0,
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5),
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
Comment on lines +167 to +170

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 Reconcile identities after retry replay

When a retryable CLOSED follows a recent event from either subscribed participant, the shared replay cursor becomes that event's timestamp minus only five seconds (relay_session.dart:608-614). Buzz accepts ordinary events with up to 15 minutes of timestamp drift (crates/buzz-relay/src/handlers/ingest.rs:2224-2230), so an agent kind:0/10100 event published during backoff with more than five seconds of negative clock skew is omitted; EOSE then marks this gate ready without rerunning either direct lookup, exposing the Huddle action. Fresh evidence beyond the prior retry-state fix is this subscription-wide cursor combined with the relay's larger accepted-skew window; reconcile the participant identities after retry recovery or use a replay window that covers accepted timestamps.

Useful? React with 👍 / 👎.

(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 +178,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 +298,116 @@ class ChannelDetailPage extends HookConsumerWidget {
channel;
final resolvedChannel =
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
final memberProfilesPreload = useMemoized(
() => _preloadMembers(ref, resolvedChannel.id),
[resolvedChannel.id, sessionStatus],
);
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,
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
),
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 thread
klopez4212 marked this conversation as resolved.
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 +449,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 +536,7 @@ class ChannelDetailPage extends HookConsumerWidget {
),
actions: resolvedChannel.isDm
? [
if (showsComposer)
if (showsHuddleAction)

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 Gate timeline Huddle controls in agent DMs

When an agent DM already contains an active huddle_started event, this hides only the app-bar button: system_rows.dart:167-175 still renders _HuddleJoinSurface, whose enabled Join, Retry, and Start new paths can open or create the unsupported Huddle. Apply the resolved agent-DM gate to these timeline controls as well.

Useful? React with 👍 / 👎.

_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