Skip to content
Draft
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
116 changes: 94 additions & 22 deletions desktop/src/features/identity-archive/hooks.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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();
Expand All @@ -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,
Expand Down Expand Up @@ -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,
};
}
3 changes: 3 additions & 0 deletions desktop/src/features/messages/ui/BotIdenticon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type BotIdenticonProps = {
/** Size in pixels (default 20) */
size?: number;
className?: string;
"data-testid"?: string;
};

/**
Expand All @@ -17,13 +18,15 @@ export const BotIdenticon = React.memo(function BotIdenticon({
value,
size = 20,
className,
"data-testid": dataTestid,
}: BotIdenticonProps) {
const svgHtml = React.useMemo(() => toSvg(value, size), [value, size]);

return (
<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 }}
Expand Down
96 changes: 96 additions & 0 deletions desktop/src/features/profile/ui/ArchiveConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading