diff --git a/desktop/src/features/identity-archive/hooks.ts b/desktop/src/features/identity-archive/hooks.ts index 401803d25..d61df7687 100644 --- a/desktop/src/features/identity-archive/hooks.ts +++ b/desktop/src/features/identity-archive/hooks.ts @@ -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, + }; +} diff --git a/desktop/src/features/profile/ui/ArchiveConfirmDialog.tsx b/desktop/src/features/profile/ui/ArchiveConfirmDialog.tsx new file mode 100644 index 000000000..5de757673 --- /dev/null +++ b/desktop/src/features/profile/ui/ArchiveConfirmDialog.tsx @@ -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 ( + + + + {title} + + Archiving removes {subject} from this space. + + + {/* The list + closing paragraph sit outside AlertDialogDescription on + purpose — that component renders a

, which can't legally contain + a