Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
184 changes: 173 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);
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: 100,
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5),
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 +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,129 @@ 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 participantCount = resolvedChannel.participantPubkeys
.map((pubkey) => pubkey.trim().toLowerCase())
.where((pubkey) => pubkey.isNotEmpty)
.toSet()
.length;
final isOneToOneDm = resolvedChannel.isDm && participantCount == 2;
final channelMembershipUpdateState = isOneToOneDm
? ref.watch(channelMembershipUpdateProvider(resolvedChannel.id))
: const ChannelMembershipUpdateState(isReady: true);
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 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 ||
!channelMembershipUpdateState.isReady ||
channelMembershipUpdateState.error != null ||
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 +469,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 +556,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
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,11 @@ final channelDetailsProvider = FutureProvider.family<ChannelDetails, String>((
/// Channel members from kind:39002 NIP-29 members event.
final channelMembersProvider = FutureProvider.autoDispose
.family<List<ChannelMember>, String>((ref, channelId) async {
ref.watch(channelMembershipUpdateProvider(channelId));
ref.watch(
channelMembershipUpdateProvider(
channelId,
).select((update) => update.version),
);
final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl;
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
final snapshotCache = ref.read(_channelMembersSnapshotCacheProvider);
Expand Down
66 changes: 57 additions & 9 deletions mobile/lib/shared/mentions/agent_identity_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -161,18 +161,32 @@ Map<String, String> mentionNamesWithDirectoryLabels({
String _agentFallbackLabel(String pubkey) =>
pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey;

/// Readiness and refresh state for a channel's live membership subscription.
class ChannelMembershipUpdateState {
final int version;
final bool isReady;
final Object? error;

const ChannelMembershipUpdateState({
this.version = 0,
this.isReady = false,
this.error,
});
}

/// Keeps the role feed alive for consumers that render mentions outside the
/// channel timeline, such as search results. A membership change refreshes the
/// shared bot-role lookup below, regardless of which surface owns the channel.
class _ChannelBotRoleSubscription extends Notifier<int> {
class _ChannelBotRoleSubscription
extends Notifier<ChannelMembershipUpdateState> {
final String channelId;
void Function()? _unsubscribe;
int _subscriptionVersion = 0;

_ChannelBotRoleSubscription(this.channelId);

@override
int build() {
ChannelMembershipUpdateState build() {
final sessionState = ref.watch(relaySessionProvider);
final subscriptionVersion = ++_subscriptionVersion;
_clearSubscription();
Expand All @@ -181,34 +195,64 @@ class _ChannelBotRoleSubscription extends Notifier<int> {
_clearSubscription();
});

if (sessionState.status != SessionStatus.connected) return 0;
if (sessionState.status != SessionStatus.connected) {
return const ChannelMembershipUpdateState();
}
Future.microtask(() => _subscribe(channelId, subscriptionVersion));
return 0;
return const ChannelMembershipUpdateState();
}

Future<void> _subscribe(String channelId, int subscriptionVersion) async {
final session = ref.read(relaySessionProvider.notifier);
var subscriptionStatus = RelaySubscriptionStatus.retrying;
try {
final unsubscribe = await session.subscribe(
final unsubscribe = await session.subscribeWithStatus(
NostrFilter(
kinds: const [39002],
tags: {
'#h': [channelId],
'#d': [channelId],
},
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000),
(_) {
if (_isCurrent(subscriptionVersion)) {
state++;
state = ChannelMembershipUpdateState(
version: state.version + 1,
isReady: true,
);
}
},
onClosed: (message) {
Comment thread
klopez4212 marked this conversation as resolved.
if (_isCurrent(subscriptionVersion)) {
state = ChannelMembershipUpdateState(
version: state.version,
error: Exception(message),
);
}
},
onStatusChanged: (status) {
subscriptionStatus = status;
if (!_isCurrent(subscriptionVersion)) return;
state = ChannelMembershipUpdateState(
version: state.version,
isReady: status == RelaySubscriptionStatus.ready,
);
},
);
if (!_isCurrent(subscriptionVersion)) {
unsubscribe();
return;
}
_unsubscribe = unsubscribe;
state = ChannelMembershipUpdateState(
version: state.version,
isReady: subscriptionStatus == RelaySubscriptionStatus.ready,
);
} catch (error) {
if (_isCurrent(subscriptionVersion)) {
state = ChannelMembershipUpdateState(
version: state.version,
error: error,
);
debugPrint(
'[ChannelBotRoleSubscription] failed for $channelId: $error',
);
Expand All @@ -229,14 +273,18 @@ class _ChannelBotRoleSubscription extends Notifier<int> {
/// changes. Channel-member and agent-role views share this source so remote
/// membership updates refresh both snapshots together.
final channelMembershipUpdateProvider = NotifierProvider.autoDispose
.family<_ChannelBotRoleSubscription, int, String>(
.family<_ChannelBotRoleSubscription, ChannelMembershipUpdateState, String>(
_ChannelBotRoleSubscription.new,
);

/// Bot pubkeys currently assigned a channel bot role.
final channelBotPubkeysProvider = FutureProvider.autoDispose
.family<Set<String>, String>((ref, channelId) async {
ref.watch(channelMembershipUpdateProvider(channelId));
ref.watch(
channelMembershipUpdateProvider(
channelId,
).select((update) => update.version),
);
final sessionState = ref.watch(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return const {};
final session = ref.read(relaySessionProvider.notifier);
Expand Down
Loading
Loading