mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Claude Opus 4.7
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent
4481f8fd5a
commit
4fce5aab2e
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user