Skip to content
Open
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
100 changes: 100 additions & 0 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 Down Expand Up @@ -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,
};
}
79 changes: 79 additions & 0 deletions desktop/src/features/profile/ui/UserProfilePanelSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as React from "react";
import type { LucideIcon } from "lucide-react";
import {
Activity,
Archive,
ArchiveRestore,
ArrowUpRight,
Brain,
ChevronDown,
Expand All @@ -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 {
Expand All @@ -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<string, string> = {
goose: "Goose",
Expand Down Expand Up @@ -116,6 +120,8 @@ export function ProfileSummaryView({
unfollowMutation,
userStatus,
}: ProfileSummaryViewProps) {
const { canArchive, isArchived } = useIdentityArchive(pubkey);

const metadataFields = [
...buildPublicFields({
pubkey,
Expand All @@ -139,10 +145,13 @@ export function ProfileSummaryView({
const showChannelsIngress =
channelsLoading || channelCount > 0 || isBot || relayAgent !== undefined;

const showManageSection = canArchive && isArchived !== undefined;

return (
<div className="flex flex-col gap-6 pt-4">
<ProfileHero
displayName={displayName}
isArchived={isArchived}
isBot={isBot}
presenceStatus={presenceStatus}
profile={profile}
Expand Down Expand Up @@ -207,6 +216,10 @@ export function ProfileSummaryView({
{metadataFields.length > 0 ? (
<ProfileFieldGroup fields={metadataFields} />
) : null}

{showManageSection ? (
<ProfileManageSection isBot={isBot} pubkey={pubkey} />
) : null}
</div>
);
}
Expand All @@ -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"];
Expand Down Expand Up @@ -263,6 +278,17 @@ function ProfileHero({
) : null}
</div>

{isArchived === true ? (
<span
className="mt-1 inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-300"
data-testid="user-profile-archived-flair"
title="This identity is archived on this relay. Historical events remain attributed to it."
>
<Archive className="h-3 w-3" />
Archived on this relay
</span>
) : null}

{profile?.about?.trim() ? (
<ProfileHeroDescription
about={profile.about.trim()}
Expand Down Expand Up @@ -479,6 +505,59 @@ function ProfileQuickAction({
);
}

// ── Manage section (archive / unarchive) ─────────────────────────────────────

// NIP-IA archive / unarchive lives in its own section under the quick-actions
// row. The relay verifies authority (self / admin / OA-owner) on submit; the
// `canArchive` gate upstream is a UX guard so the button only renders when at
// least one path will be accepted.
function ProfileManageSection({
isBot,
pubkey,
}: {
isBot: boolean;
pubkey: string;
}) {
const { isArchived, isPending, archive, unarchive } =
useIdentityArchive(pubkey);

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>
{isArchived ? (
<Button
className="w-full"
data-testid="user-profile-unarchive-identity"
disabled={isPending}
onClick={unarchive}
type="button"
variant="secondary"
>
<ArchiveRestore className="h-4 w-4" />
{isPending ? "Unarchiving…" : unarchiveLabel}
</Button>
) : (
<Button
className="w-full"
data-testid="user-profile-archive-identity"
disabled={isPending}
onClick={archive}
type="button"
variant="secondary"
>
<Archive className="h-4 w-4" />
{isPending ? "Archiving…" : archiveLabel}
</Button>
)}
</section>
);
}

// ── Field rows ───────────────────────────────────────────────────────────────

type ProfileField = {
Expand Down
115 changes: 115 additions & 0 deletions desktop/tests/e2e/identity-archive.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading