feat(desktop): restore archive identity UI in profile panel (#961)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-06-25 20:34:49 -04:00
committed by GitHub
co-authored by Claude Opus 4.7 npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent 4481f8fd5a
commit 4fce5aab2e
9 changed files with 587 additions and 56 deletions
@@ -17,7 +17,10 @@ use tauri::State;
use crate::{
app_state::AppState,
events,
relay::{query_relay, submit_event, SubmitEventResponse},
relay::{
classify_request_error, query_relay, relay_http_base_url, relay_ws_url_with_override,
submit_event, SubmitEventResponse,
},
};
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -218,38 +221,45 @@ pub struct ArchivedIdentitiesSnapshot {
pub archived: Vec<String>,
}
/// Read the relay's latest `kind:13535` archive snapshot. The frontend caches
/// this and tests membership client-side to drive the "Archived" flair.
///
/// Per spec §Snapshot and Delta Consistency: the latest valid `kind:13535`
/// signed by the relay identity is authoritative.
///
/// NIP-IA §Client Behavior says clients MUST verify the snapshot is signed by
/// the relay's NIP-11 `self` key. We do not yet filter by that key here: the
/// desktop only ever talks to its own configured relay, where server-side
/// enforcement makes archive state trustworthy, and we have no NIP-11 `self`
/// fetch wired up (the sibling relay-signed kind:13534 membership list is
/// consumed the same way). Author-filtering against NIP-11 `self` is the
/// correct hardening for an untrusted/multi-relay client and is tracked as a
/// follow-up — not a runtime gap on Buzz's relay.
#[tauri::command]
pub async fn list_archived_identities(
state: State<'_, AppState>,
) -> Result<ArchivedIdentitiesSnapshot, String> {
let events = query_relay(
&state,
&[serde_json::json!({
"kinds": [13535],
"limit": 1,
})],
)
.await?;
#[derive(Debug, Deserialize)]
struct RelayInformationDocument {
#[serde(default, rename = "self")]
self_: Option<String>,
}
let Some(snapshot) = events.into_iter().next() else {
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
async fn fetch_relay_self(state: &AppState) -> Result<Option<String>, String> {
let relay_url = relay_ws_url_with_override(state);
let http_url = relay_http_base_url(&relay_url);
let response = state
.http_client
.get(&http_url)
.header("Accept", "application/nostr+json")
.send()
.await
.map_err(|e| classify_request_error(&e))?;
if !response.status().is_success() {
return Ok(None);
}
let doc = response
.json::<RelayInformationDocument>()
.await
.map_err(|_| "relay returned malformed NIP-11 document".to_string())?;
let Some(relay_self) = doc.self_.map(|value| value.to_ascii_lowercase()) else {
return Ok(None);
};
let archived = snapshot
if relay_self.len() == 64 && relay_self.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(Some(relay_self))
} else {
Ok(None)
}
}
fn archived_pubkeys_from_snapshot(snapshot: &nostr::Event) -> Vec<String> {
snapshot
.tags
.iter()
.filter_map(|t| {
@@ -262,9 +272,50 @@ pub async fn list_archived_identities(
}
None
})
.collect();
.collect()
}
Ok(ArchivedIdentitiesSnapshot { archived })
/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend
/// caches this and tests membership client-side to drive the "Archived" flair.
///
/// Per NIP-IA §Client Behavior and §Snapshot and Delta Consistency, only a
/// snapshot signed by the relay identity advertised in NIP-11 `self` can affect
/// archive state. If the relay has no stable `self`, fail open with an empty
/// snapshot rather than trusting unauthenticated relay-authoritative state.
#[tauri::command]
pub async fn list_archived_identities(
state: State<'_, AppState>,
) -> Result<ArchivedIdentitiesSnapshot, String> {
let Some(relay_self) = fetch_relay_self(&state).await? else {
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
};
let events = query_relay(
&state,
&[serde_json::json!({
"authors": [relay_self.clone()],
"kinds": [13535],
"limit": 1,
})],
)
.await?;
let Some(snapshot) = events.into_iter().next() else {
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
};
// Defense-in-depth: the filter should already restrict author, but the
// client must still reject malformed or wrongly signed relay state.
if !snapshot.verify_id() || !snapshot.verify_signature() {
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
}
if !snapshot.pubkey.to_hex().eq_ignore_ascii_case(&relay_self) {
return Ok(ArchivedIdentitiesSnapshot { archived: vec![] });
}
Ok(ArchivedIdentitiesSnapshot {
archived: archived_pubkeys_from_snapshot(&snapshot),
})
}
// ── Tests ───────────────────────────────────────────────────────────────────
@@ -317,6 +368,38 @@ mod tests {
assert!(extract_oa_owner(&kind0).is_none());
}
#[test]
fn archived_pubkeys_from_snapshot_accepts_only_valid_p_tags() {
let relay = Keys::generate();
let valid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let uppercase = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let snapshot = EventBuilder::new(Kind::Custom(13535), "")
.tags([
Tag::parse(["-"]).unwrap(),
Tag::parse(["p", valid]).unwrap(),
Tag::parse(["p", uppercase]).unwrap(),
Tag::parse(["p", "not-hex"]).unwrap(),
])
.sign_with_keys(&relay)
.unwrap();
let expected = vec![valid.to_string(), uppercase.to_ascii_lowercase()];
assert_eq!(archived_pubkeys_from_snapshot(&snapshot), expected);
}
#[test]
fn relay_information_document_reads_nip11_self_field() {
let doc: RelayInformationDocument = serde_json::from_str(
r#"{"name":"test relay","self":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#,
)
.expect("NIP-11 document");
assert_eq!(
doc.self_.as_deref(),
Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
);
}
/// Spec test-vector regression for gotcha #3: the NIP-OA preimage subject
/// is the *target/agent* pubkey, not the request signer. The vectors in
/// `docs/nips/NIP-IA.md` §Test Vectors fix concrete values; verifying the
+94 -22
View File
@@ -1,6 +1,8 @@
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import {
archiveIdentity,
@@ -24,11 +26,7 @@ export function useArchivedIdentitiesQuery(enabled = true) {
});
}
/**
* `true` iff `pubkey` appears in the relay's latest archive snapshot.
* Returns `undefined` while the snapshot is loading so callers can hide the
* flair until we know.
*/
/** `undefined` while the snapshot loads so callers can defer the flair. */
export function useIsIdentityArchived(pubkey: string): boolean | undefined {
const query = useArchivedIdentitiesQuery();
if (!query.data) return undefined;
@@ -38,20 +36,16 @@ export function useIsIdentityArchived(pubkey: string): boolean | undefined {
/**
* Predicate for hiding archived identities from forward-looking discovery
* surfaces (mention autocomplete, DM picker, member-adder, search,
* panel-fold). Distinct from `useIsIdentityArchived` because callers here
* need a synchronous boolean: while the `kind:13535` snapshot is loading the
* predicate returns `false` (no-op — show everyone), never `true` — fail-open
* so a cold-start can't briefly hide everyone.
* surfaces (autocomplete, DM picker, member-adder, search, panel-fold).
* Fail-open: returns `false` while the snapshot loads so a cold start can't
* briefly hide everyone.
*
* Self-exempt by construction: the current user is **never** filtered or
* folded from their own client, even when archived on the relay. NIP-IA §Self
* Requests makes archival deliberately non-silent — the anti-shadowban
* property requires the archived user to see they're archived and be able to
* self-unarchive. The profile pane's "Archived" flair is the honest
* disclosure; removing self from member lists / autocomplete / search would
* build the exact shadowban the NIP is designed to prevent. Self-exemption
* lives here, in the predicate, so no caller can forget it.
* Self-exempt by construction: the current user is never folded from their own
* client, even when archived on the relay. NIP-IA §Self Requests makes archival
* deliberately non-silent — the anti-shadowban property requires the archived
* user to see they're archived and self-unarchive. Folding self would build the
* exact shadowban the NIP prevents, so the exemption lives here in the
* predicate where no caller can forget it.
*/
export function useIsArchivedPredicate(): (pubkey: string) => boolean {
const query = useArchivedIdentitiesQuery();
@@ -69,10 +63,7 @@ export function useIsArchivedPredicate(): (pubkey: string) => boolean {
}, [query.data, selfPubkey]);
}
/**
* Resolve the NIP-OA owner of a target via its live `kind:0`. Gates the
* owner-path archive button.
*/
/** Gates the owner-path archive button via the target's live `kind:0`. */
export function useOaOwnerQuery(pubkey: string, enabled = true) {
return useQuery({
enabled,
@@ -105,3 +96,84 @@ export function useUnarchiveIdentityMutation() {
},
});
}
/** Everything the profile panel needs to gate + drive NIP-IA archival. */
export type IdentityArchiveActions = {
/** Render guard only — the relay re-verifies authority on submit. */
canArchive: boolean;
/** `undefined` while the snapshot loads — defer flair + Manage until known. */
isArchived: boolean | undefined;
isPending: boolean;
archive: () => void;
unarchive: () => void;
};
/**
* Self-contained NIP-IA archive controller for a single `pubkey`. Composes the
* gate queries, owns both mutations, and exposes archive/unarchive with toasts.
*
* Safe to call from multiple components on the same `pubkey`: React Query
* dedupes the underlying subscriptions by queryKey, so a second hook call costs
* a render, not a second network round-trip.
*/
export function useIdentityArchive(pubkey: string): IdentityArchiveActions {
const identityQuery = useIdentityQuery();
const currentPubkey = identityQuery.data?.pubkey;
const pubkeyLower = pubkey.toLowerCase();
const isSelf =
currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase();
const myMembershipQuery = useMyRelayMembershipQuery();
// Skip the kind:0 lookup when viewing yourself — the OA gate is for
// archiving *other* identities you own. Also defer until our own identity
// resolves so we never fire the lookup against an unknown viewer.
const oaOwnerQuery = useOaOwnerQuery(
pubkey,
currentPubkey !== undefined && !isSelf,
);
const isArchived = useIsIdentityArchived(pubkey);
const archiveMutation = useArchiveIdentityMutation();
const unarchiveMutation = useUnarchiveIdentityMutation();
const myRole = myMembershipQuery.data?.role;
const isRelayAdminOrOwner = myRole === "owner" || myRole === "admin";
const isOaOwnerOfViewee = oaOwnerQuery.data?.isMe === true;
const canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee;
const archive = React.useCallback(() => {
archiveMutation.mutate(
{ targetPubkey: pubkey },
{
onSuccess: () => toast.success("Archived on this relay"),
onError: (error) =>
toast.error(
`Archive failed: ${error instanceof Error ? error.message : String(error)}`,
),
},
);
}, [archiveMutation, pubkey]);
const unarchive = React.useCallback(() => {
unarchiveMutation.mutate(
{ targetPubkey: pubkey },
{
onSuccess: () => toast.success("Unarchived on this relay"),
onError: (error) =>
toast.error(
`Unarchive failed: ${error instanceof Error ? error.message : String(error)}`,
),
},
);
}, [pubkey, unarchiveMutation]);
return {
canArchive,
isArchived,
isPending: archiveMutation.isPending || unarchiveMutation.isPending,
archive,
unarchive,
};
}
@@ -7,6 +7,7 @@ type BotIdenticonProps = {
/** Size in pixels (default 20) */
size?: number;
className?: string;
"data-testid"?: string;
};
/**
@@ -17,6 +18,7 @@ export const BotIdenticon = React.memo(function BotIdenticon({
value,
size = 20,
className,
"data-testid": dataTestid,
}: BotIdenticonProps) {
const svgHtml = React.useMemo(() => toSvg(value, size), [value, size]);
@@ -24,6 +26,7 @@ export const BotIdenticon = React.memo(function BotIdenticon({
<div
aria-hidden
className={className}
data-testid={dataTestid}
// biome-ignore lint/security/noDangerouslySetInnerHtml: jdenticon produces safe SVG
dangerouslySetInnerHTML={{ __html: svgHtml }}
style={{ width: size, height: size, flexShrink: 0 }}
@@ -0,0 +1,96 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button, buttonVariants } from "@/shared/ui/button";
// Archive is relay-scoped + reversible (NIP-IA), so this gates with a calm,
// reassuring confirmation rather than a destructive warning. The confirm action
// renders `secondary` to match the trigger and the non-alarming tone — we pass
// the secondary classes straight to `AlertDialogAction` (whose base style is the
// default/primary variant) so tailwind-merge overrides the primary background;
// `asChild` + a nested Button would concatenate both variants and leave the
// primary fill winning on source order.
export function ArchiveConfirmDialog({
open,
onOpenChange,
onConfirm,
onGoToAgents,
isBot,
isPending,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
onGoToAgents: () => void;
isBot: boolean;
isPending: boolean;
}) {
const title = isBot ? "Archive this agent?" : "Archive this identity?";
const subject = isBot ? "this agent" : "this person";
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent data-testid="archive-confirm-dialog">
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>
Archiving removes {subject} from this space.
</AlertDialogDescription>
</AlertDialogHeader>
{/* The list + closing paragraph sit outside AlertDialogDescription on
purpose — that component renders a <p>, which can't legally contain
a <ul> or another block <p>. */}
<ul className="list-disc space-y-1.5 pl-5 text-sm text-muted-foreground">
<li>
They won't appear in search, autocomplete, or when adding members
</li>
<li>
This only affects{" "}
<span className="font-medium text-foreground">this space</span>
not their account anywhere else
</li>
<li>You can unarchive them at any time to restore them</li>
</ul>
{isBot ? (
<p className="text-sm text-muted-foreground">
To permanently remove this agent instead, delete it in the{" "}
<Button
className="h-auto p-0 align-baseline text-sm"
onClick={() => {
onOpenChange(false);
onGoToAgents();
}}
type="button"
variant="link"
>
Agents tab
</Button>
.
</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "secondary" })}
data-testid="archive-confirm-action"
disabled={isPending}
onClick={onConfirm}
>
{isPending ? "Archiving…" : "Archive"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,65 @@
import * as React from "react";
import { Archive, ArchiveRestore } from "lucide-react";
import type { IdentityArchiveActions } from "@/features/identity-archive/hooks";
import { ArchiveConfirmDialog } from "@/features/profile/ui/ArchiveConfirmDialog";
import { Button } from "@/shared/ui/button";
export function ProfileManageArchiveSection({
archiveActions,
isBot,
onGoToAgents,
}: {
archiveActions: IdentityArchiveActions;
isBot: boolean;
onGoToAgents: () => void;
}) {
const [confirmOpen, setConfirmOpen] = React.useState(false);
const archiveLabel = isBot ? "Archive agent" : "Archive identity";
const unarchiveLabel = isBot ? "Unarchive agent" : "Unarchive identity";
return (
<section className="flex flex-col gap-2">
<h4 className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
Manage
</h4>
{archiveActions.isArchived ? (
<Button
className="w-full"
data-testid="user-profile-unarchive-identity"
disabled={archiveActions.isPending}
onClick={archiveActions.unarchive}
type="button"
variant="secondary"
>
<ArchiveRestore className="h-4 w-4" />
{archiveActions.isPending ? "Unarchiving…" : unarchiveLabel}
</Button>
) : (
<Button
className="w-full"
data-testid="user-profile-archive-identity"
disabled={archiveActions.isPending}
onClick={() => setConfirmOpen(true)}
type="button"
variant="secondary"
>
<Archive className="h-4 w-4" />
{archiveActions.isPending ? "Archiving…" : archiveLabel}
</Button>
)}
<ArchiveConfirmDialog
isBot={isBot}
isPending={archiveActions.isPending}
onConfirm={() => {
archiveActions.archive();
setConfirmOpen(false);
}}
onGoToAgents={onGoToAgents}
onOpenChange={setConfirmOpen}
open={confirmOpen}
/>
</section>
);
}
@@ -22,6 +22,7 @@ import {
useProfileQuery,
useUnfollowMutation,
useUserProfileQuery,
useUsersBatchQuery,
} from "@/features/profile/hooks";
import {
ChannelsFocusedView,
@@ -164,6 +165,10 @@ export function UserProfilePanel({
const relayAgentsQuery = useRelayAgentsQuery({ enabled: true });
const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
// kind:0-derived agent flag (verified NIP-OA `auth` tag). The relay-agent
// registry and local managed-agent list can both miss an owned agent that was
// deployed elsewhere, but the archive UI still needs bot-aware copy.
const usersBatchQuery = useUsersBatchQuery([pubkey]);
const channelsQuery = useChannelsQuery();
const presenceQuery = usePresenceQuery([pubkey]);
const userStatusQuery = useUserStatusQuery([pubkey]);
@@ -186,7 +191,10 @@ export function UserProfilePanel({
const managedAgent = managedAgentsQuery.data?.find(
(agent) => agent.pubkey.toLowerCase() === pubkeyLower,
);
const isBot = Boolean(relayAgent || managedAgent);
const isAgentByOaOwner = Boolean(
usersBatchQuery.data?.profiles[pubkeyLower]?.isAgent,
);
const isBot = Boolean(relayAgent || managedAgent) || isAgentByOaOwner;
// Does THIS desktop hold the agent's seckey? Gates edit (which needs the key)
// and grants owner access when the agent is managed locally.
const isOwner = useIsManagedAgent(isBot ? pubkey : null);
@@ -27,6 +27,7 @@ import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { getPresenceLabel } from "@/features/presence/lib/presence";
import { useIdentityArchive } from "@/features/identity-archive/hooks";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import type {
useFollowMutation,
@@ -34,6 +35,7 @@ import type {
useUserProfileQuery,
} from "@/features/profile/hooks";
import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity";
import { ProfileManageArchiveSection } from "@/features/profile/ui/ProfileManageArchiveSection";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
@@ -131,8 +133,9 @@ export function ProfileSummaryView({
unfollowMutation,
userStatus,
}: ProfileSummaryViewProps) {
const { goChannel } = useAppNavigation();
const { goAgents, goChannel } = useAppNavigation();
const activeTurns = useActiveAgentTurns(isBot ? pubkey : null);
const archiveActions = useIdentityArchive(pubkey);
const metadataFields = [
...buildPublicFields({
@@ -165,6 +168,7 @@ export function ProfileSummaryView({
<div className="flex flex-col gap-6 pt-4">
<ProfileHero
displayName={displayName}
isArchived={archiveActions.isArchived === true}
isBot={isBot}
presenceStatus={presenceStatus}
profile={profile}
@@ -243,6 +247,16 @@ export function ProfileSummaryView({
{metadataFields.length > 0 ? (
<ProfileFieldGroup fields={metadataFields} />
) : null}
{archiveActions.canArchive && archiveActions.isArchived !== undefined ? (
<ProfileManageArchiveSection
archiveActions={archiveActions}
isBot={isBot}
onGoToAgents={() => {
void goAgents();
}}
/>
) : null}
</div>
);
}
@@ -275,12 +289,14 @@ function ProfileWorkingBadge({
function ProfileHero({
displayName,
isArchived,
isBot,
presenceStatus,
profile,
userStatus,
}: {
displayName: string;
isArchived: boolean;
isBot: boolean;
presenceStatus: "online" | "away" | "offline" | undefined;
profile: ProfileSummaryViewProps["profile"];
@@ -314,9 +330,18 @@ function ProfileHero({
<h3 className="text-xl font-semibold tracking-tight">
{displayName}
</h3>
{isArchived ? (
<Badge
data-testid="user-profile-archived-flair"
variant="secondary"
>
Archived
</Badge>
) : null}
{isBot ? (
<BotIdenticon
className="shrink-0 rounded"
data-testid="profile-bot-indicator"
size={20}
value={displayName}
/>
+125
View File
@@ -0,0 +1,125 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// NIP-IA archive button + "Archived" flair gate matrix.
//
// Guards the composition `canArchive = isSelf || isRelayAdminOrOwner ||
// isOaOwnerOfViewee` in UserProfilePanel.tsx. Unit tests cover each input in
// isolation; this spec covers the OR composition where silent regressions
// (refactor turns OR into AND, role expansion bypasses a branch, etc.) would
// otherwise slip past code review.
const ALICE_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
async function openSelfProfile(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// First seed message in #general is from the active identity.
const firstMessage = page.getByTestId("message-row").first();
await firstMessage.locator("button", { hasText: "npub1mock..." }).click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
}
async function openAliceProfile(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Second seed message in #general is from Alice. Her display name "alice"
// is registered in mockDisplayNames, so the author button text is "alice".
const aliceMessage = page.getByTestId("message-row").nth(1);
await aliceMessage.locator("button", { hasText: "alice" }).first().click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible();
await expect(panel).toContainText(ALICE_PUBKEY.slice(0, 8));
}
test.describe("NIP-IA archive button gate", () => {
test("case 1 — self viewer + self target: Archive visible, no flair", async ({
page,
}) => {
await installMockBridge(page, { relayRole: null, oaOwnerIsMe: false });
await openSelfProfile(page);
const archiveButton = page.getByTestId("user-profile-archive-identity");
await expect(archiveButton).toBeVisible();
await expect(page.getByTestId("user-profile-archived-flair")).toHaveCount(
0,
);
// Archive is now gated behind a confirmation modal — clicking the button
// opens the dialog rather than firing immediately. Drive the full flow so
// the gate stays meaningful: the modal must surface, then confirm fires.
await expect(page.getByTestId("archive-confirm-dialog")).toHaveCount(0);
await archiveButton.click();
await expect(page.getByTestId("archive-confirm-dialog")).toBeVisible();
const confirm = page.getByTestId("archive-confirm-action");
await expect(confirm).toBeVisible();
await confirm.click();
await expect(page.getByTestId("archive-confirm-dialog")).toHaveCount(0);
});
test("case 2 — relay admin viewing Alice: Archive visible", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: "admin",
oaOwnerIsMe: false,
archivedIdentities: [],
});
await openAliceProfile(page);
await expect(
page.getByTestId("user-profile-archive-identity"),
).toBeVisible();
});
test("case 3 — verified OA owner viewing Alice: Archive visible", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: null,
oaOwnerIsMe: true,
archivedIdentities: [],
});
await openAliceProfile(page);
await expect(
page.getByTestId("user-profile-archive-identity"),
).toBeVisible();
});
test("case 4 — no authority viewing Alice: Archive hidden", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: null,
oaOwnerIsMe: false,
archivedIdentities: [],
});
await openAliceProfile(page);
await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount(
0,
);
await expect(
page.getByTestId("user-profile-unarchive-identity"),
).toHaveCount(0);
});
test("case 5 — Alice archived: flair + Unarchive button (under admin gate)", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: "admin",
oaOwnerIsMe: false,
archivedIdentities: [ALICE_PUBKEY],
});
await openAliceProfile(page);
await expect(page.getByTestId("user-profile-archived-flair")).toBeVisible();
await expect(
page.getByTestId("user-profile-unarchive-identity"),
).toBeVisible();
await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount(
0,
);
});
});
+54
View File
@@ -687,6 +687,60 @@ test("renders agent memories seeded through the Playwright mock bridge", async (
await expect(page.getByTestId("agent-memory-list")).toContainText("orphan");
});
test("owned agent absent from relay/managed lists still renders agent framing", async ({
page,
}) => {
// Regression: bot-detection used to rely solely on the relay-agents registry
// + the local managed-agents list. An owned agent deployed elsewhere can miss
// BOTH lists, so the panel rendered it as a human (wrong archive framing).
// The fix ORs in the kind:0 NIP-OA agent flag (same signal the archive gate
// trusts), surfaced via the users-batch summary's `isAgent`.
const ednaPubkey =
"16aaadcf39011edbd887e4abefe5837170621db277e234f3f6c220d38ba75ecf";
await installMockBridge(page, {
// Seeded as an agent (kind:0 NIP-OA owner) but NOT as a managed agent and
// NOT in the relay-agents registry — exactly the bug scenario.
searchProfiles: [
{ pubkey: ednaPubkey, displayName: "Edna", isAgent: true },
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ pubkey }) => {
const emit = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
pubkey: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) {
throw new Error("Mock message emitter is unavailable.");
}
emit({ channelName: "general", content: "Edna check-in", pubkey });
},
{ pubkey: ednaPubkey },
);
const messageRow = page
.getByTestId("message-row")
.filter({ hasText: "Edna check-in" });
await expect(messageRow).toBeVisible();
await messageRow.locator("button").first().click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
// The bot indicator only renders when isBot resolves true — the assertion
// that the OA-owner signal now drives agent framing.
await expect(page.getByTestId("profile-bot-indicator")).toBeVisible();
});
test("renders settings in the app shell with a back button", async ({
page,
}) => {