diff --git a/desktop/src/features/identity-archive/hooks.ts b/desktop/src/features/identity-archive/hooks.ts index 401803d25..14ad81aea 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, @@ -105,3 +107,101 @@ export function useUnarchiveIdentityMutation() { }, }); } + +/** Everything the profile panel needs to gate + drive NIP-IA archival. */ +export type IdentityArchiveActions = { + /** + * UX gate. `true` when ANY auth path will be accepted by the relay: self + * (acting on own pubkey), relay admin/owner, or verified NIP-OA owner of the + * viewee. The relay re-verifies on submit — this is purely a render guard. + */ + canArchive: boolean; + /** + * `true` iff the target is in the relay's latest `kind:13535` snapshot. + * `undefined` while the snapshot loads so callers can defer the flair / + * Manage section until authority + state are both known. + */ + isArchived: boolean | undefined; + /** Either mutation in flight — drives the disabled + "Archiving…" states. */ + isPending: boolean; + /** Submit a `kind:9035` archive request for `pubkey` (toasts on result). */ + archive: () => void; + /** Submit a `kind:9036` unarchive request for `pubkey` (toasts on result). */ + unarchive: () => void; +}; + +/** + * Self-contained NIP-IA archive controller for a single `pubkey`. Composes the + * three gate queries, owns both mutations, and exposes the archive/unarchive + * callbacks with toasts — collapsing what used to be six props drilled through + * the profile panel into one hook call. + * + * Safe to call from multiple components on the same `pubkey`: React Query + * dedupes the underlying subscriptions by queryKey, so the only cost is a + * second hook invocation, not a second network round-trip. + * + * Gate composition is verbatim from the old `UserProfilePanel`: + * `canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee`. + */ +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/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 05a97e82f..4d3654145 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -2,6 +2,8 @@ import * as React from "react"; import type { LucideIcon } from "lucide-react"; import { Activity, + Archive, + ArchiveRestore, ArrowUpRight, Brain, ChevronDown, @@ -23,6 +25,7 @@ import { toast } from "sonner"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import { useIdentityArchive } from "@/features/identity-archive/hooks"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { @@ -36,6 +39,7 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; const RUNTIME_LABELS: Record = { goose: "Goose", @@ -116,6 +120,8 @@ export function ProfileSummaryView({ unfollowMutation, userStatus, }: ProfileSummaryViewProps) { + const { canArchive, isArchived } = useIdentityArchive(pubkey); + const metadataFields = [ ...buildPublicFields({ pubkey, @@ -139,10 +145,13 @@ export function ProfileSummaryView({ const showChannelsIngress = channelsLoading || channelCount > 0 || isBot || relayAgent !== undefined; + const showManageSection = canArchive && isArchived !== undefined; + return (
0 ? ( ) : null} + + {showManageSection ? ( + + ) : null}
); } @@ -215,12 +228,14 @@ export function ProfileSummaryView({ function ProfileHero({ displayName, + isArchived, isBot, presenceStatus, profile, userStatus, }: { displayName: string; + isArchived: boolean | undefined; isBot: boolean; presenceStatus: "online" | "away" | "offline" | undefined; profile: ProfileSummaryViewProps["profile"]; @@ -263,6 +278,17 @@ function ProfileHero({ ) : null} + {isArchived === true ? ( + + + Archived on this relay + + ) : null} + {profile?.about?.trim() ? ( +

+ Manage +

+ {isArchived ? ( + + ) : ( + + )} + + ); +} + // ── Field rows ─────────────────────────────────────────────────────────────── type ProfileField = { diff --git a/desktop/tests/e2e/identity-archive.spec.ts b/desktop/tests/e2e/identity-archive.spec.ts new file mode 100644 index 000000000..58498569d --- /dev/null +++ b/desktop/tests/e2e/identity-archive.spec.ts @@ -0,0 +1,115 @@ +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); + await expect( + page.getByTestId("user-profile-archive-identity"), + ).toBeVisible(); + await expect(page.getByTestId("user-profile-archived-flair")).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, + ); + }); +});