Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions mobile/lib/features/channels/channel_actions_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ class ChannelActionsSheet extends HookConsumerWidget {
orElse: () => null,
);
final ownsOwnerAgent =
!agentOwnersAsync.isLoading &&
!agentOwnersAsync.hasError &&
currentPubkey != null &&
membersAsync.value?.any(
(member) =>
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart' show ProviderListenable;
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const _huddleSpeakingRingSize = 112.0;
const _huddleParticipantLabelSpace = 28.0;
const _huddleDenseParticipantThreshold = 6;

/// Exposes the actual participant profile producer without starting native audio.
@visibleForTesting
ProviderListenable<int> debugHuddleProfileUpdates(String channelId) =>
_huddleParticipantProfileUpdatesProvider(channelId);

final _huddleParticipantProfileUpdatesProvider = NotifierProvider.autoDispose
.family<_HuddleParticipantProfileUpdates, int, String>(
_HuddleParticipantProfileUpdates.new,
Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/features/channels/channel_details_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ class ChannelDetailsPage extends HookConsumerWidget {
orElse: () => null,
);
final ownsOwnerAgent =
!agentOwnersAsync.isLoading &&
!agentOwnersAsync.hasError &&
resolvedCurrentPubkey != null &&
members.any(
(member) =>
Expand Down
62 changes: 53 additions & 9 deletions mobile/lib/shared/mentions/agent_identity_provider.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../shared/crypto/nip_oa.dart';
import '../profile/user_cache_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/utils/string_utils.dart';

Expand Down Expand Up @@ -74,18 +75,61 @@ final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
/// profiles. An entry exists only when the `auth` tag verifies — mirrors
/// desktop's `profile_valid_oa_owner_pubkey`.
final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
ref.watch(userCacheProvider);
final cache = ref.read(userCacheProvider.notifier);
final source = ref.watch(_agentOwnerProfilesProvider);
final ready =
source.asData?.value ??
await ref.watch(_agentOwnerProfilesProvider.future);
if (ready != true) throw StateError('Owner profile feed unavailable');
return cache.profileOwners;
});

// The live REQ carries history too; only EOSE (not acquisition timeout) is ready.
final _agentOwnerProfilesProvider = StreamProvider<bool>((ref) async* {
ref.watch(relayConfigProvider);
final admission = ref.read(userCacheProvider.notifier).captureAdmission();
if (ref.watch(relaySessionProvider).status != SessionStatus.connected) {
yield false;
return;
}
final availability = StreamController<bool>()..add(false);
var disposed = false;
void Function()? unsubscribe;
ref.onDispose(() {
disposed = true;
unsubscribe?.call();
availability.close();
});
final agents = await ref.watch(agentDirectoryProvider.future);
if (agents.isEmpty) return const {};
if (disposed) return;
if (agents.isEmpty) {
yield true;
return;
}
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
final filter = NostrFilters.profilesBatch([
for (final agent in agents) agent.pubkey,
]);
unsubscribe = await session.subscribeWithStatus(
filter,
(event) {
if (!disposed) admission.add(event);
},
onClosed: (_) {
if (!disposed) availability.add(false);
},
onStatusChanged: (status) {
if (!disposed) {
availability.add(status == RelaySubscriptionStatus.ready);
}
},
);
final owners = <String, String>{};
for (final event in latestProfileEvents(events).values) {
final owner = verifiedOaOwnerPubkey(event);
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
if (disposed) {
unsubscribe();
return;
}
return owners;
yield* availability.stream;
});

/// Pubkeys currently known to represent agents across the active relay.
Expand Down
50 changes: 38 additions & 12 deletions mobile/test/features/channels/channel_detail_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,29 @@ NostrEvent _edit({
sig: '',
);

Future<Widget> admissionDmHarness(List<String> pubkeys) async {
SharedPreferences.setMockInitialValues({});
_testPrefs = await SharedPreferences.getInstance();
return _buildTestable(
messages: const [],
realAdmission: true,
channel: Channel(
id: _channelId,
name: 'DM',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: pubkeys.first,
createdAt: DateTime(2025),
memberCount: 2,
participantPubkeys: pubkeys,
isMember: true,
),
);
}

Widget _buildTestable({
bool realAdmission = false,
required List<NostrEvent> messages,
List<TypingEntry> typing = const [],
Map<String, UserProfile> users = const {},
Expand Down Expand Up @@ -266,9 +288,10 @@ Widget _buildTestable({
huddleTypingNotifier ??
_FakeTypingNotifier(const [], channelId: _huddleChannelId),
),
userCacheProvider.overrideWith(
() => userCacheNotifier ?? _FakeUserCacheNotifier(users),
),
if (!realAdmission)
userCacheProvider.overrideWith(
() => userCacheNotifier ?? _FakeUserCacheNotifier(users),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
channelsProvider.overrideWith(() => fakeChannelsNotifier),
channelStarsProvider.overrideWith(_FakeChannelStarsNotifier.new),
Expand Down Expand Up @@ -303,12 +326,14 @@ Widget _buildTestable({
if (member.isBot) member.pubkey.toLowerCase(),
},
),
agentOwnersProvider.overrideWith(
(ref) async => loadAgentOwners?.call() ?? const <String, String>{},
),
agentDirectoryProvider.overrideWith(
(ref) async => loadAgentDirectory?.call() ?? const [],
),
if (!realAdmission)
agentOwnersProvider.overrideWith(
(ref) async => loadAgentOwners?.call() ?? const <String, String>{},
),
if (!realAdmission)
agentDirectoryProvider.overrideWith(
(ref) async => loadAgentDirectory?.call() ?? const [],
),
if (knownAgentPubkeys != null)
knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys),
if (directoryUsers != null)
Expand Down Expand Up @@ -348,9 +373,10 @@ Widget _buildTestable({
),
mediaHttpClientProvider.overrideWithValue(mediaClient),
],
if (relaySessionNotifier != null ||
(resolvedChannel.isDm &&
resolvedChannel.participantPubkeys.toSet().length == 2))
if (!realAdmission &&
(relaySessionNotifier != null ||
(resolvedChannel.isDm &&
resolvedChannel.participantPubkeys.toSet().length == 2)))
relaySessionProvider.overrideWith(
() => relaySessionNotifier ?? _IdentityUpdateRelaySession(),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ void main() {
),
],
);
container.listen(agentOwnersProvider, (_, _) {});
expect(
await container.read(agentOwnersProvider.future),
expected == null ? isEmpty : {agent.public: expected},
Expand Down Expand Up @@ -938,4 +939,18 @@ class _DirectoryFakeRelaySession extends RelaySessionNotifier {
}) async {
return profileEvents;
}

@override
Future<void Function()> subscribeWithStatus(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
void Function(RelaySubscriptionStatus status)? onStatusChanged,
}) async {
for (final event in profileEvents) {
onEvent(event);
}
onStatusChanged?.call(RelaySubscriptionStatus.ready);
return () {};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ void main() {
sharedChannelIds: {'chan-1'},
userCache: const {},
ownerByAgentPubkey: const {},
ownerSourceAvailable: false,
searchResults: [
UserProfile(
pubkey: agentPubkey,
Expand Down
61 changes: 61 additions & 0 deletions mobile/test/shared/crypto/nip_oa_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,65 @@ void main() {
isNull,
);
});
test('rejects ambiguous auth and invalid envelopes', () {
final tag = authTag(owner, agent.public);
final valid = profile(agent, [tag]);
final badTags = [
for (final duplicate in [
tag,
['auth'],
['auth', 'invalid'],
]) ...[
[tag, duplicate],
[duplicate, tag],
],
for (final index in [1, 3])
[List<String>.from(tag)..[index] = tag[index].toUpperCase()],
];
for (final event in [
for (final tags in badTags) profile(agent, tags),
profile(agent, [tag], kind: 1),
for (final patch in [
{'content': 'forged'},
{'created_at': 101},
{'id': '0' * 64},
{'sig': '0' * 128},
{'pubkey': owner.public},
])
NostrEvent.fromJson({...valid.toJson(), ...patch}),
]) {
expect(verifiedOaOwnerPubkey(event), isNull);
}
});
test('conditions evaluate the signed profile time, with strict bounds', () {
const valid = [
'',
'kind=0',
'created_at>99&created_at<101',
'created_at<4294967295',
];
for (final conditions in [
...valid,
'kind=1',
'created_at>100',
'created_at<100',
'kind=65536',
'kind=00',
'kind=+0',
'created_at<4294967296',
'kind=0&',
' kind=0',
'kind=0&kind=1',
]) {
expect(
verifiedOaOwnerPubkey(
profile(agent, [
authTag(owner, agent.public, conditions: conditions),
]),
),
valid.contains(conditions) ? owner.public : isNull,
reason: conditions,
);
}
});
}
Loading
Loading