From 847f8174798ee74cacfdcb696fee4e75a2775595 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 10 Jun 2026 15:11:18 -0700 Subject: [PATCH 1/3] feat(desktop): restore archive identity UI in profile panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Manage section below the quick-actions row with a full-width Archive / Unarchive button, plus the "Archived on this relay" flair under the displayName. Gated by the original three-path composition (self / relay admin or owner / NIP-OA owner of viewee) — the relay re-verifies authority on submit. Button label flips to "Archive agent" on bot profiles. Restores the 5-case e2e gate matrix that #917 dropped alongside the UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../features/profile/ui/UserProfilePanel.tsx | 60 +++++++++ .../profile/ui/UserProfilePanelSections.tsx | 104 ++++++++++++++++ desktop/tests/e2e/identity-archive.spec.ts | 115 ++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 desktop/tests/e2e/identity-archive.spec.ts diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index ba65d4559..7e1523390 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { ArrowLeft, X } from "lucide-react"; +import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { @@ -13,6 +14,12 @@ import { } from "@/features/agents/hooks"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { + useArchiveIdentityMutation, + useIsIdentityArchived, + useOaOwnerQuery, + useUnarchiveIdentityMutation, +} from "@/features/identity-archive/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import { useContactListQuery, @@ -26,6 +33,7 @@ import { MemoryFocusedView, ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; +import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; @@ -160,6 +168,17 @@ export function UserProfilePanel({ const contactListQuery = useContactListQuery(currentPubkey); const followMutation = useFollowMutation(currentPubkey); const unfollowMutation = useUnfollowMutation(currentPubkey); + const myMembershipQuery = useMyRelayMembershipQuery(); + // Skip the kind:0 lookup when viewing yourself — the OA gate is for + // archiving *other* identities you own. + const oaOwnerQuery = useOaOwnerQuery( + pubkey, + currentPubkey !== undefined && + pubkey.toLowerCase() !== currentPubkey.toLowerCase(), + ); + const isArchived = useIsIdentityArchived(pubkey); + const archiveMutation = useArchiveIdentityMutation(); + const unarchiveMutation = useUnarchiveIdentityMutation(); const { onOpenAgentSession } = useAgentSession(); const { goChannel } = useAppNavigation(); @@ -183,6 +202,15 @@ export function UserProfilePanel({ const isSelf = currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); const canViewActivity = isOwner === true && Boolean(onOpenAgentSession); + // NIP-IA gate composition. The button shows when ANY of: self path (acting + // on own pubkey), admin path (current user is owner/admin in relay_members), + // or OA-owner path (current user is the verified NIP-OA owner of the viewee + // per its live kind:0). The relay re-verifies authority on submit; this gate + // is purely a UX guard. + const myRole = myMembershipQuery.data?.role; + const isRelayAdminOrOwner = myRole === "owner" || myRole === "admin"; + const isOaOwnerOfViewee = oaOwnerQuery.data?.isMe === true; + const canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee; const isFollowing = !isSelf && (contactListQuery.data?.contacts.some( @@ -228,6 +256,32 @@ export function UserProfilePanel({ [goChannel], ); + const handleArchive = 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 handleUnarchive = 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]); + const displayName = profile?.displayName ?? truncatePubkey(pubkey); const ownerHandle = React.useMemo(() => { if (currentPubkey === undefined) { @@ -294,15 +348,20 @@ export function UserProfilePanel({ > {view === "summary" ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 05a97e82f..03bf60c14 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,10 @@ import { toast } from "sonner"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import type { + useArchiveIdentityMutation, + useUnarchiveIdentityMutation, +} from "@/features/identity-archive/hooks"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { @@ -36,6 +42,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", @@ -56,15 +63,20 @@ async function copyToClipboard(value: string, label?: string) { // ── Summary view ───────────────────────────────────────────────────────────── export type ProfileSummaryViewProps = { + archiveMutation: ReturnType; + canArchive: boolean; canEditAgent: boolean; canViewActivity: boolean; channelCount: number; channelsLoading: boolean; displayName: string; followMutation: ReturnType; + handleArchive: () => void; handleEditAgent: () => void; handleMessage: () => void; handleOpenActivity: () => void; + handleUnarchive: () => void; + isArchived: boolean | undefined; isBot: boolean; isFollowing: boolean; isOwner: boolean | undefined; @@ -82,20 +94,26 @@ export type ProfileSummaryViewProps = { profile: ReturnType["data"]; pubkey: string; relayAgent: RelayAgent | undefined; + unarchiveMutation: ReturnType; unfollowMutation: ReturnType; userStatus: { text: string; emoji: string } | null | undefined; }; export function ProfileSummaryView({ + archiveMutation, + canArchive, canEditAgent, canViewActivity, channelCount, channelsLoading, displayName, followMutation, + handleArchive, handleEditAgent, handleMessage, handleOpenActivity, + handleUnarchive, + isArchived, isBot, isFollowing, isOwner, @@ -113,6 +131,7 @@ export function ProfileSummaryView({ profile, pubkey, relayAgent, + unarchiveMutation, unfollowMutation, userStatus, }: ProfileSummaryViewProps) { @@ -139,10 +158,13 @@ export function ProfileSummaryView({ const showChannelsIngress = channelsLoading || channelCount > 0 || isBot || relayAgent !== undefined; + const showManageSection = canArchive && isArchived !== undefined; + return (
) : null} + {showManageSection ? ( + + ) : null} + {showMemoriesIngress || showChannelsIngress || canViewActivity ? (
{showMemoriesIngress ? ( @@ -215,12 +248,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 +298,17 @@ function ProfileHero({ ) : null}
+ {isArchived === true ? ( + + + Archived on this relay + + ) : null} + {profile?.about?.trim() ? ( ; + isArchived: boolean; + isBot: boolean; + onArchive: () => void; + onUnarchive: () => void; + unarchiveMutation: ReturnType; +}) { + const archiveLabel = isBot ? "Archive agent" : "Archive identity"; + const unarchiveLabel = isBot ? "Unarchive agent" : "Unarchive identity"; + + return ( +
+

+ 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, + ); + }); +}); From 446672954e6c9f928ce123437c33af695cf3d492 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 12 Jun 2026 15:05:28 -0700 Subject: [PATCH 2/3] refactor(desktop): collapse archive prop drilling into useIdentityArchive hook Introduces useIdentityArchive(pubkey) in features/identity-archive/hooks.ts, composing the three gate queries (relay membership, OA owner, archived status) plus currentPubkey, owning both mutations and the toasted archive/unarchive callbacks. Returns { canArchive, isArchived, isPending, archive, unarchive }. Both call sites (ProfileSummaryView and ProfileManageSection) consume the hook directly, removing the six drilled props from UserProfilePanel and the now-dead hook calls, handlers, and imports. Gate composition is preserved verbatim: isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/identity-archive/hooks.ts | 100 ++++++++++++++++++ .../features/profile/ui/UserProfilePanel.tsx | 60 ----------- .../profile/ui/UserProfilePanelSections.tsx | 55 +++------- 3 files changed, 115 insertions(+), 100 deletions(-) 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/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 7e1523390..ba65d4559 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { ArrowLeft, X } from "lucide-react"; -import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { @@ -14,12 +13,6 @@ import { } from "@/features/agents/hooks"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; import { useChannelsQuery } from "@/features/channels/hooks"; -import { - useArchiveIdentityMutation, - useIsIdentityArchived, - useOaOwnerQuery, - useUnarchiveIdentityMutation, -} from "@/features/identity-archive/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import { useContactListQuery, @@ -33,7 +26,6 @@ import { MemoryFocusedView, ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; -import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; @@ -168,17 +160,6 @@ export function UserProfilePanel({ const contactListQuery = useContactListQuery(currentPubkey); const followMutation = useFollowMutation(currentPubkey); const unfollowMutation = useUnfollowMutation(currentPubkey); - const myMembershipQuery = useMyRelayMembershipQuery(); - // Skip the kind:0 lookup when viewing yourself — the OA gate is for - // archiving *other* identities you own. - const oaOwnerQuery = useOaOwnerQuery( - pubkey, - currentPubkey !== undefined && - pubkey.toLowerCase() !== currentPubkey.toLowerCase(), - ); - const isArchived = useIsIdentityArchived(pubkey); - const archiveMutation = useArchiveIdentityMutation(); - const unarchiveMutation = useUnarchiveIdentityMutation(); const { onOpenAgentSession } = useAgentSession(); const { goChannel } = useAppNavigation(); @@ -202,15 +183,6 @@ export function UserProfilePanel({ const isSelf = currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); const canViewActivity = isOwner === true && Boolean(onOpenAgentSession); - // NIP-IA gate composition. The button shows when ANY of: self path (acting - // on own pubkey), admin path (current user is owner/admin in relay_members), - // or OA-owner path (current user is the verified NIP-OA owner of the viewee - // per its live kind:0). The relay re-verifies authority on submit; this gate - // is purely a UX guard. - const myRole = myMembershipQuery.data?.role; - const isRelayAdminOrOwner = myRole === "owner" || myRole === "admin"; - const isOaOwnerOfViewee = oaOwnerQuery.data?.isMe === true; - const canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee; const isFollowing = !isSelf && (contactListQuery.data?.contacts.some( @@ -256,32 +228,6 @@ export function UserProfilePanel({ [goChannel], ); - const handleArchive = 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 handleUnarchive = 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]); - const displayName = profile?.displayName ?? truncatePubkey(pubkey); const ownerHandle = React.useMemo(() => { if (currentPubkey === undefined) { @@ -348,20 +294,15 @@ export function UserProfilePanel({ > {view === "summary" ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 03bf60c14..4932d0149 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -25,10 +25,7 @@ import { toast } from "sonner"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; -import type { - useArchiveIdentityMutation, - useUnarchiveIdentityMutation, -} from "@/features/identity-archive/hooks"; +import { useIdentityArchive } from "@/features/identity-archive/hooks"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { @@ -63,20 +60,15 @@ async function copyToClipboard(value: string, label?: string) { // ── Summary view ───────────────────────────────────────────────────────────── export type ProfileSummaryViewProps = { - archiveMutation: ReturnType; - canArchive: boolean; canEditAgent: boolean; canViewActivity: boolean; channelCount: number; channelsLoading: boolean; displayName: string; followMutation: ReturnType; - handleArchive: () => void; handleEditAgent: () => void; handleMessage: () => void; handleOpenActivity: () => void; - handleUnarchive: () => void; - isArchived: boolean | undefined; isBot: boolean; isFollowing: boolean; isOwner: boolean | undefined; @@ -94,26 +86,20 @@ export type ProfileSummaryViewProps = { profile: ReturnType["data"]; pubkey: string; relayAgent: RelayAgent | undefined; - unarchiveMutation: ReturnType; unfollowMutation: ReturnType; userStatus: { text: string; emoji: string } | null | undefined; }; export function ProfileSummaryView({ - archiveMutation, - canArchive, canEditAgent, canViewActivity, channelCount, channelsLoading, displayName, followMutation, - handleArchive, handleEditAgent, handleMessage, handleOpenActivity, - handleUnarchive, - isArchived, isBot, isFollowing, isOwner, @@ -131,10 +117,11 @@ export function ProfileSummaryView({ profile, pubkey, relayAgent, - unarchiveMutation, unfollowMutation, userStatus, }: ProfileSummaryViewProps) { + const { canArchive, isArchived } = useIdentityArchive(pubkey); + const metadataFields = [ ...buildPublicFields({ pubkey, @@ -184,14 +171,7 @@ export function ProfileSummaryView({ ) : null} {showManageSection ? ( - + ) : null} {showMemoriesIngress || showChannelsIngress || canViewActivity ? ( @@ -532,20 +512,15 @@ function ProfileQuickAction({ // `canArchive` gate upstream is a UX guard so the button only renders when at // least one path will be accepted. function ProfileManageSection({ - archiveMutation, - isArchived, isBot, - onArchive, - onUnarchive, - unarchiveMutation, + pubkey, }: { - archiveMutation: ReturnType; - isArchived: boolean; isBot: boolean; - onArchive: () => void; - onUnarchive: () => void; - unarchiveMutation: ReturnType; + pubkey: string; }) { + const { isArchived, isPending, archive, unarchive } = + useIdentityArchive(pubkey); + const archiveLabel = isBot ? "Archive agent" : "Archive identity"; const unarchiveLabel = isBot ? "Unarchive agent" : "Unarchive identity"; @@ -558,25 +533,25 @@ function ProfileManageSection({ ) : ( )} From 234b8c17e46bdf75d6fa20b5a5319bca1ba1f6e5 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 12 Jun 2026 15:06:05 -0700 Subject: [PATCH 3/3] refactor(desktop): move Manage section below profile metadata fields Relocates the Archive/Unarchive Manage section so it renders after the metadata field group instead of above the memories/channels ingress, placing identity management at the bottom of the profile summary. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/profile/ui/UserProfilePanelSections.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 4932d0149..4d3654145 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -170,10 +170,6 @@ export function ProfileSummaryView({ /> ) : null} - {showManageSection ? ( - - ) : null} - {showMemoriesIngress || showChannelsIngress || canViewActivity ? (
{showMemoriesIngress ? ( @@ -220,6 +216,10 @@ export function ProfileSummaryView({ {metadataFields.length > 0 ? ( ) : null} + + {showManageSection ? ( + + ) : null} ); }