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
99 changes: 99 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,100 @@ 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,
};
}
190 changes: 190 additions & 0 deletions desktop/src/features/profile/ui/ProfileActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import type { LucideIcon } from "lucide-react";
import {
ChevronRight,
MessageSquare,
Pencil,
UserMinus,
UserPlus,
} from "lucide-react";
import { toast } from "sonner";

import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import type {
useFollowMutation,
useUnfollowMutation,
} from "@/features/profile/hooks";
import { cn } from "@/shared/lib/cn";
import { useNow } from "@/shared/lib/useNow";
import { Badge } from "@/shared/ui/badge";

export function ProfileWorkingBadge({
channelId,
name,
observedAt,
onNavigate,
}: {
channelId: string;
name: string;
observedAt: number;
onNavigate: (channelId: string) => void;
}) {
const now = useNow(1000);

return (
<Badge
className="cursor-pointer motion-safe:animate-pulse normal-case tracking-normal hover:opacity-80"
variant="default"
onClick={() => onNavigate(channelId)}
>
Working in #{name} · {formatElapsed(now - observedAt)}
</Badge>
);
}

export function ProfilePrimaryActions({
canEditAgent,
followMutation,
isFollowing,
onEditAgent,
onMessage,
pubkey,
unfollowMutation,
}: {
canEditAgent: boolean;
followMutation: ReturnType<typeof useFollowMutation>;
isFollowing: boolean;
onEditAgent: () => void;
onMessage?: () => void;
pubkey: string;
unfollowMutation: ReturnType<typeof useUnfollowMutation>;
}) {
return (
<div className="flex items-start justify-center gap-8">
{isFollowing ? (
<ProfileQuickAction
active
disabled={unfollowMutation.isPending}
icon={UserMinus}
label="Unfollow"
onClick={() =>
unfollowMutation.mutate(pubkey, {
onError: (error) =>
toast.error(
`Unfollow failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
}
/>
) : (
<ProfileQuickAction
disabled={followMutation.isPending}
icon={UserPlus}
label="Follow"
onClick={() =>
followMutation.mutate(pubkey, {
onError: (error) =>
toast.error(
`Follow failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
}
/>
)}
{onMessage ? (
<ProfileQuickAction
icon={MessageSquare}
label="Message"
onClick={onMessage}
testId="user-profile-message"
/>
) : null}
{canEditAgent ? (
<ProfileQuickAction
icon={Pencil}
label="Edit"
onClick={onEditAgent}
testId="user-profile-edit-agent"
/>
) : null}
</div>
);
}

function ProfileQuickAction({
active,
disabled,
icon: Icon,
label,
onClick,
testId,
}: {
active?: boolean;
disabled?: boolean;
icon: LucideIcon;
label: string;
onClick: () => void;
testId?: string;
}) {
return (
<button
className="flex flex-col items-center gap-2 disabled:cursor-not-allowed disabled:opacity-50"
data-testid={testId}
disabled={disabled}
onClick={onClick}
type="button"
>
<span
className={cn(
"flex h-14 w-14 items-center justify-center rounded-full transition-colors",
active
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted/60 text-foreground hover:bg-muted/80",
)}
>
<Icon className="h-5 w-5" />
</span>
<span
className={cn(
"text-xs",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{label}
</span>
</button>
);
}

export function ProfileIngressRow({
icon: Icon,
label,
onClick,
testId,
trailing,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
testId: string;
trailing?: string;
}) {
return (
<button
className="flex w-full items-center gap-3 rounded-2xl bg-muted/20 px-4 py-2 text-left transition-colors hover:bg-muted/40"
data-testid={testId}
onClick={onClick}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1 text-sm font-medium text-foreground">
{label}
</span>
{trailing ? (
<span className="text-sm text-muted-foreground">{trailing}</span>
) : null}
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
);
}
Loading