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
6 changes: 5 additions & 1 deletion desktop/tests/e2e/forum-agent-invitation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,11 @@ for (const stage of ["add", "publish"] as const) {
await expect(
page.getByRole("button", { name: "Remove attachment" }),
).toBeVisible();
if (replacement !== null)
if (replacement === "") {
await page.getByTestId("message-input").press("ControlOrMeta+a");
await page.getByTestId("message-input").press("Backspace");
await expect(page.getByTestId("message-input")).toBeEmpty();
} else if (replacement !== null)
await page.getByTestId("message-input").fill(replacement);
await navigate(1);
await releaseForumGate(page);
Expand Down
55 changes: 54 additions & 1 deletion desktop/tests/e2e/profile-custom-emoji-status.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,14 @@ test("set status dialog uses the desktop modal with shared status choices", asyn
test("keeps an open status draft when the saved status expires", async ({
page,
}) => {
// Pin the browser Date before navigation so the seeded two-second status
// cannot expire while the popover/editor setup runs, however slow the
// machine; timers, network, and rendering keep running, and the seeded
// node-side timestamps match the pinned Date exactly.
const seededAt = new Date("2026-09-10T12:00:00.000Z");
const nowSeconds = Math.floor(seededAt.getTime() / 1_000);
await page.clock.setFixedTime(seededAt);
await page.goto("/");
const nowSeconds = Math.floor(Date.now() / 1_000);
await seedMockStatus(page, {
text: "Original draft",
emoji: "📝",
Expand All @@ -206,7 +212,22 @@ test("keeps an open status draft when the saved status expires", async ({
});
await page.getByTestId("profile-popover-set-status").click();
const dialog = page.getByTestId("set-status-dialog");
// The editor opened on the still-saved status, not on a fresh one.
await expect(dialog.getByTestId("set-status-input")).toHaveValue(
"Original draft",
);
await expect(dialog.getByTestId("set-status-duration")).toContainText(
"Custom",
);
await expect(dialog.getByLabel("Choose a status emoji")).toContainText("📝");
await expect(dialog.getByTestId("set-status-clear")).toBeVisible();
await expect(dialog.getByText("Quick statuses", { exact: true })).toHaveCount(
0,
);
await dialog.getByTestId("set-status-input").fill("Unsaved draft");
// Advance the pinned Date past the deadline, then fire the pending timer.
await page.clock.setFixedTime(new Date(seededAt.getTime() + 3_000));
await page.clock.fastForward(2_500);
await expect(page.getByTestId("sidebar-profile-user-status")).toHaveCount(0, {
timeout: 5_000,
});
Expand All @@ -227,6 +248,38 @@ test("keeps an open status draft when the saved status expires", async ({
await expect(dialog.getByTestId("set-status-clear")).toBeVisible();
});

test("opens a fresh editor when the saved status expired before opening", async ({
page,
}) => {
// Causal control for the setup ordering above: the same pinned clock and
// seed, but the deadline is crossed before the editor opens. The dialog
// must then be a new-status editor — the saved-editor precondition
// assertions above would fail under this wrong ordering.
const seededAt = new Date("2026-09-10T12:00:00.000Z");
const nowSeconds = Math.floor(seededAt.getTime() / 1_000);
await page.clock.setFixedTime(seededAt);
await page.goto("/");
await seedMockStatus(page, {
text: "Original draft",
emoji: "📝",
expiresAt: nowSeconds + 2,
createdAt: nowSeconds,
});
await page.clock.setFixedTime(new Date(seededAt.getTime() + 5_000));
await page.clock.fastForward(5_000);
await expect(page.getByTestId("sidebar-profile-user-status")).toHaveCount(0);
await page.getByTestId("profile-popover-set-status").click();
const dialog = page.getByTestId("set-status-dialog");
await expect(dialog.getByTestId("set-status-input")).toHaveValue("");
await expect(dialog.getByTestId("set-status-duration")).toContainText(
"Today",
);
await expect(
dialog.getByText("Quick statuses", { exact: true }),
).toBeVisible();
await expect(dialog.getByTestId("set-status-clear")).toHaveCount(0);
});

test("new statuses default to expiring at local midnight", async ({ page }) => {
await page.goto("/");
await openProfilePopover(page);
Expand Down
13 changes: 10 additions & 3 deletions mobile/lib/features/channels/mentions/mention_candidates.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ List<MentionCandidate> buildMentionCandidates({
required Map<String, String> ownerByAgentPubkey,
List<UserProfile> searchResults = const [],
String? currentPubkey,
bool directoryReady = true,
}) {
final candidates = <MentionCandidate>[];
final seen = <String>{};
Expand All @@ -68,8 +69,10 @@ List<MentionCandidate> buildMentionCandidates({
final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey;
final isAgent = member.isBot || ownerPubkey != null;
final policy = relayAgents.where((agent) => agent.pubkey == pk).firstOrNull;
if (policy?.ownerPubkey != null &&
!agentIsSharedWithUser(policy!, sharedChannelIds, currentPubkey)) {
if (ownerPubkey != null &&
(!directoryReady ||
policy?.ownerPubkey == null ||
!agentIsSharedWithUser(policy!, sharedChannelIds, currentPubkey))) {
continue;
}
candidates.add(
Expand Down Expand Up @@ -98,6 +101,7 @@ List<MentionCandidate> buildMentionCandidates({
}

for (final agent in relayAgents) {
if (!directoryReady && agent.ownerPubkey != null) continue;
final pk = agent.pubkey;
if (seen.contains(pk)) continue;
if (!sharedAgentPubkeys.contains(pk)) continue;
Expand Down Expand Up @@ -135,7 +139,10 @@ List<MentionCandidate> buildMentionCandidates({
final policy = relayAgents
.where((agent) => agent.pubkey == pk)
.firstOrNull;
if (policy?.ownerPubkey != null && !sharedAgentPubkeys.contains(pk)) {
if (ownerPubkey != null &&
(!directoryReady ||
policy?.ownerPubkey == null ||
!sharedAgentPubkeys.contains(pk))) {
continue;
}
if (!ownedByCurrentUser && !sharedAgentPubkeys.contains(pk)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ final mentionCandidatesProvider = Provider.family
sessionStatus: sessionStatus,
cachedMembers: cachedMembers,
);
final directory = ref.watch(agentDirectoryProvider);
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
directory.asData?.value ?? const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final channels = channelsAsync.asData?.value ?? const <Channel>[];
final userCache = ref.watch(userCacheProvider);
Expand All @@ -96,6 +96,7 @@ final mentionCandidatesProvider = Provider.family
final candidates = buildMentionCandidates(
members: members,
relayAgents: relayAgents,
directoryReady: !directory.isLoading && !directory.hasError,
sharedChannelIds: sharedChannelIds,
userCache: userCache,
ownerByAgentPubkey: owners,
Expand Down
68 changes: 20 additions & 48 deletions mobile/lib/features/profile/profile_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../shared/crypto/nip_oa.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import '../../shared/relay/relay.dart';
Expand Down Expand Up @@ -69,9 +68,7 @@ final profileAvatarHandoffProvider =
/// WebSocket. Returns null when no nsec is configured or when the user has
/// not yet published a profile.
class ProfileNotifier extends AsyncNotifier<UserProfile?> {
Map<String, dynamic> _metadata = {};
bool _hasHydrated = false;
int _lastCreatedAt = 0;
Future<void> _patchQueue = Future.value();

@override
Expand All @@ -92,37 +89,19 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
final myPk = context.pubkey;
if (myPk == null) {
_requireCurrentWriteContext(context);
_metadata = {};
_lastCreatedAt = 0;
_hasHydrated = true;
return null;
}

final session = context.session;
final events = await session.fetchHistory(NostrFilters.profile(myPk));
if (events.isEmpty) {
_requireCurrentWriteContext(context);
_metadata = {};
_lastCreatedAt = 0;
_hasHydrated = true;
return null;
}
final latest = _latestProfileEvent(events)!;
final metadata = _decodeProfileMetadata(latest);
final data = ProfileData.fromEvent(latest);
final profile = UserProfile(
pubkey: data.pubkey,
displayName: data.displayName,
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(latest),
);
_requireCurrentWriteContext(context);
_metadata = metadata;
_lastCreatedAt = latest.createdAt;
final cache = ref.read(userCacheProvider.notifier);
for (final event in events) {
cache.cacheProfileEvent(event);
}
_hasHydrated = true;
return profile;
return ref.read(userCacheProvider)[myPk.toLowerCase()];
}

Future<void> refresh() async {
Expand Down Expand Up @@ -182,9 +161,12 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
NostrFilters.profile(pubkey),
);
_requireCurrentWriteContext(context);
final currentHead = _latestProfileEvent(currentEvents);
if (_lastCreatedAt > 0 &&
(currentHead == null || currentHead.createdAt < _lastCreatedAt)) {
final cache = ref.read(userCacheProvider.notifier);
for (final event in currentEvents) {
cache.cacheProfileEvent(event);
}
final currentHead = cache.profileEvent(pubkey);
if (currentHead?.id != _latestProfileEvent(currentEvents)?.id) {
throw StateError('Cannot confirm the latest profile metadata.');
}
final currentMetadata = currentHead == null
Expand All @@ -199,10 +181,7 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
final relay = SignedEventRelay(session: session, nsec: context.config.nsec);
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final currentCreatedAt = currentHead?.createdAt ?? 0;
final previousCreatedAt = currentCreatedAt > _lastCreatedAt
? currentCreatedAt
: _lastCreatedAt;
final createdAt = now > previousCreatedAt ? now : previousCreatedAt + 1;
final createdAt = now > currentCreatedAt ? now : currentCreatedAt + 1;
NostrEvent? signedEvent;
await relay.submit(
kind: EventKind.profile,
Expand All @@ -216,27 +195,20 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
if (submittedEvent == null) {
throw StateError('Profile update was not signed.');
}
final verifiedHead = _latestProfileEvent(
await session.fetchHistory(NostrFilters.profile(pubkey)),
final verification = await session.fetchHistory(
NostrFilters.profile(pubkey),
);
_requireCurrentWriteContext(context);
for (final event in verification) {
cache.cacheProfileEvent(event);
}
final verifiedHead = _latestProfileEvent(verification);
if (verifiedHead?.id != submittedEvent.id) {
throw StateError('Profile changed before the update could be confirmed.');
}

_metadata = nextMetadata;
_lastCreatedAt = createdAt;
final profile = UserProfile(
pubkey: pubkey,
displayName:
_metadata['display_name'] as String? ?? _metadata['name'] as String?,
avatarUrl: _metadata['picture'] as String?,
about: _metadata['about'] as String?,
nip05Handle: _metadata['nip05'] as String?,
ownerPubkey: verifiedOaOwnerPubkey(submittedEvent),
);
state = AsyncData(profile);
ref.read(userCacheProvider.notifier).put(profile);
cache.cacheProfileEvent(submittedEvent);
state = AsyncData(ref.read(userCacheProvider)[pubkey.toLowerCase()]);
}

void _requireCurrentWriteContext(_ProfileWriteContext context) {
Expand Down
5 changes: 5 additions & 0 deletions mobile/lib/shared/mentions/agent_authorization.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ part of 'agent_identity_provider.dart';

/// Fresh exact-key policy and relay-signed membership. Directory entries are
/// hints; only this read may authorize agent mentions at a destination.
/// [resolveProfileOwners] optionally folds fetched profiles into an ordered,
/// session-bound cache after the scope check and returns its current verified
/// owners. Omit it for side-effect-free send-time reads.
Future<List<AgentDirectoryEntry>> readAgentAuthorization(
RelaySessionNotifier session,
Set<String> requestedKeys, {
required String? viewer,
String? channelId,
required bool Function() isCurrent,
Map<String, String> Function(Iterable<NostrEvent>)? resolveProfileOwners,
}) async {
void check() {
if (!isCurrent()) throw StateError('Agent authorization scope changed');
Expand Down Expand Up @@ -66,6 +70,7 @@ Future<List<AgentDirectoryEntry>> readAgentAuthorization(
runtime.where((e) => requestedKeys.contains(e.pubkey)).toList(),
requestedKeys: requestedKeys,
checkCurrent: check,
resolveProfileOwners: resolveProfileOwners,
);
check();
final latest = <String, NostrEvent>{};
Expand Down
4 changes: 4 additions & 0 deletions mobile/lib/shared/mentions/agent_discovery.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ class _AgentDirectoryUpdates extends Notifier<int> {
),
(event) {
if (!current()) return;
if (disposed) return;
if (event.kind == 0) {
ref.read(userCacheProvider.notifier).cacheProfileEvent(event);
}
if (event.kind == 5 && !_isAgentCoordinateDeletion(event)) return;
changed();
},
Expand Down
40 changes: 27 additions & 13 deletions mobile/lib/shared/mentions/agent_identity_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/crypto/signed_event.dart';
import '../../shared/relay/relay.dart';
import '../profile/user_cache_provider.dart';

part 'agent_policy.dart';
part 'agent_authorization.dart';
Expand Down Expand Up @@ -92,28 +93,41 @@ final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
if (!current()) return [];
return readAgentAuthorization(
session,
{...owned, ...events.map((event) => event.pubkey)},
{
...owned, ...events.map((event) => event.pubkey),
// Refresh previously authenticated owners even after runtime/policy
// discovery stops returning them (for example across reconnect).
for (final profile in ref.read(userCacheProvider).values)
if (profile.ownerPubkey != null) profile.pubkey,
},
viewer: viewer,
isCurrent: current,
resolveProfileOwners: (profiles) {
if (!current()) throw StateError('Profile authority scope changed');
final cache = ref.read(userCacheProvider.notifier);
for (final profile in profiles) {
cache.cacheProfileEvent(profile);
}
return {
for (final profile in ref.read(userCacheProvider).values)
if (profile.ownerPubkey != null) profile.pubkey: profile.ownerPubkey!,
};
},
);
});

/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0
/// 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 {
final agents = await ref.watch(agentDirectoryProvider.future);
if (agents.isEmpty) return const {};
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
);
final owners = <String, String>{};
for (final event in latestProfileEvents(events).values) {
final owner = verifiedOaOwnerPubkey(event);
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
}
return owners;
await ref.watch(agentDirectoryProvider.future);
// The profile cache is the single ordered ownership source for lifecycle,
// provenance and mention fallbacks; never re-query a parallel owner snapshot.
final profiles = ref.watch(userCacheProvider);
return {
for (final profile in profiles.values)
if (profile.ownerPubkey != null) profile.pubkey: profile.ownerPubkey!,
};
});

/// Pubkeys currently known to represent agents across the active relay.
Expand Down
Loading
Loading