Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
64 changes: 56 additions & 8 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Future<void> _preloadMembers(WidgetRef ref, String channelId) async {
final members = await ref.read(channelMembersProvider(channelId).future);
final pubkeys = members.map((m) => m.pubkey).toList();
if (pubkeys.isNotEmpty) {
notifier.preload(pubkeys);
await notifier.preload(pubkeys);
}
} catch (_) {
// Non-fatal — mentions will just fall back to cache from messages.
Expand Down Expand Up @@ -146,6 +146,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 +266,54 @@ 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 isAgentIdentityLoading =
isOneToOneDm &&
(agentDirectoryState.isLoading ||
agentOwnersState.isLoading ||
channelBotPubkeysState.isLoading ||
memberProfilesPreloadState.connectionState != ConnectionState.done);
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
final showsHuddleAction =
showsComposer &&
!isAgentIdentityLoading &&
!_isOneToOneAgentDm(resolvedChannel, agentPubkeys);
final messagesNotifier = ref.read(
channelMessagesProvider(channel.id).notifier,
);
Expand Down Expand Up @@ -301,12 +355,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 +442,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
18 changes: 15 additions & 3 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<void>? _batchCompleter;

@override
Map<String, UserProfile> build() {
ref.watch(relayConfigProvider);
ref.onDispose(() {
_batchTimer?.cancel();
_batchTimer = null;
_batchCompleter?.complete();
_batchCompleter = null;
});
return {};
}
Expand All @@ -34,14 +37,18 @@ 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
Future<void> 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();
_pending.addAll(uncached);
final completer = _batchCompleter ??= Completer<void>();
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
return completer.future;
}

/// Applies a live kind:0 profile event to the cache.
Expand All @@ -57,6 +64,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<void>();
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
}

Expand All @@ -66,6 +74,8 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {

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

try {
final session = ref.read(relaySessionProvider.notifier);
Expand All @@ -82,6 +92,8 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
state = updated;
} catch (_) {
// Silently fail — we'll just show pubkeys.
} finally {
completer?.complete();
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
}
}

Expand Down
Loading
Loading