mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
* docs: add applesauce v5 upgrade plan Comprehensive migration plan covering: - Package updates (add applesauce-common, update to v5) - EventFactory import migration (applesauce-factory → applesauce-core) - Unified event loader setup - ActionHub → ActionRunner migration - useObservableMemo → use$ hook migration - New features: casting system, encrypted content caching - Documentation and skills updates needed * feat: upgrade applesauce libraries to v5 Major upgrade from applesauce v4 to v5 with breaking changes: Package updates: - applesauce-core: ^4.0.0 → ^5.0.0 - applesauce-actions: ^4.0.0 → ^5.0.0 - applesauce-loaders: ^4.0.0 → ^5.0.0 - applesauce-react: ^4.0.0 → ^5.0.0 - applesauce-relay: ^4.0.0 → ^5.0.0 - applesauce-signers: ^4.0.0 → ^5.0.0 - applesauce-accounts: ^4.0.0 → ^5.0.0 - Added new applesauce-common: ^5.0.0 package API migrations: - EventFactory: applesauce-factory → applesauce-core/event-factory - ActionHub → ActionRunner with async function pattern (not generators) - useObservableMemo → use$ hook across all components - Helper imports: article, highlight, threading, zap, comment, lists moved from applesauce-core to applesauce-common - parseCoordinate → parseReplaceableAddress - Subscription options: retries → reconnect - getEventPointerFromETag now returns null instead of throwing New features: - Unified event loader via createEventLoaderForStore - Updated loaders.ts to use v5 unified loader pattern Documentation: - Updated CLAUDE.md with v5 patterns and migration notes - Updated applesauce-core skill for v5 changes - Created new applesauce-common skill Test fixes: - Updated publish-spellbook.test.ts for v5 ActionRunner pattern - Updated publish-spell.test.ts with eventStore mock - Updated relay-selection.test.ts with valid test events - Updated loaders.test.ts with valid 64-char hex event IDs - Added createEventLoaderForStore mock --------- Co-authored-by: Claude <noreply@anthropic.com>
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
import { useProfile } from "@/hooks/useProfile";
|
|
import { UserName } from "./nostr/UserName";
|
|
import Nip05 from "./nostr/nip05";
|
|
import { ProfileCardSkeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Copy,
|
|
CopyCheck,
|
|
User as UserIcon,
|
|
Inbox,
|
|
Send,
|
|
Wifi,
|
|
} from "lucide-react";
|
|
import { kinds, nip19 } from "nostr-tools";
|
|
import { useEventStore, use$ } from "applesauce-react/hooks";
|
|
import { getInboxes, getOutboxes } from "applesauce-core/helpers/mailboxes";
|
|
import { useCopy } from "../hooks/useCopy";
|
|
import { RichText } from "./nostr/RichText";
|
|
import { RelayLink } from "./nostr/RelayLink";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "./ui/dropdown-menu";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
import { useRelayState } from "@/hooks/useRelayState";
|
|
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
|
import { addressLoader } from "@/services/loaders";
|
|
import { relayListCache } from "@/services/relay-list-cache";
|
|
import { useEffect } from "react";
|
|
import type { Subscription } from "rxjs";
|
|
import { useGrimoire } from "@/core/state";
|
|
|
|
export interface ProfileViewerProps {
|
|
pubkey: string;
|
|
}
|
|
|
|
/**
|
|
* ProfileViewer - Detailed view for a user profile
|
|
* Shows profile metadata, inbox/outbox relays, and raw JSON
|
|
*/
|
|
export function ProfileViewer({ pubkey }: ProfileViewerProps) {
|
|
const { state } = useGrimoire();
|
|
const accountPubkey = state.activeAccount?.pubkey;
|
|
|
|
// Resolve $me alias
|
|
const resolvedPubkey = pubkey === "$me" ? accountPubkey : pubkey;
|
|
|
|
const profile = useProfile(resolvedPubkey);
|
|
const eventStore = useEventStore();
|
|
const { copy, copied } = useCopy();
|
|
const { relays: relayStates } = useRelayState();
|
|
|
|
// Fetch fresh relay list from network only if not cached or stale
|
|
useEffect(() => {
|
|
let subscription: Subscription | null = null;
|
|
if (!resolvedPubkey) return;
|
|
|
|
// Check if we have a valid cached relay list
|
|
relayListCache.has(resolvedPubkey).then(async (hasCached) => {
|
|
if (hasCached) {
|
|
console.debug(
|
|
`[ProfileViewer] Using cached relay list for ${resolvedPubkey.slice(0, 8)}`,
|
|
);
|
|
|
|
// Load cached event into EventStore so UI can display it
|
|
const cached = await relayListCache.get(resolvedPubkey);
|
|
if (cached?.event) {
|
|
eventStore.add(cached.event);
|
|
console.debug(
|
|
`[ProfileViewer] Loaded cached relay list into EventStore for ${resolvedPubkey.slice(0, 8)}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// No cached or stale - fetch fresh from network
|
|
console.debug(
|
|
`[ProfileViewer] Fetching fresh relay list for ${resolvedPubkey.slice(0, 8)}`,
|
|
);
|
|
subscription = addressLoader({
|
|
kind: kinds.RelayList,
|
|
pubkey: resolvedPubkey,
|
|
identifier: "",
|
|
}).subscribe({
|
|
error: (err) => {
|
|
console.debug(
|
|
`[ProfileViewer] Failed to fetch relay list for ${resolvedPubkey.slice(0, 8)}:`,
|
|
err,
|
|
);
|
|
},
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
if (subscription) {
|
|
subscription.unsubscribe();
|
|
}
|
|
};
|
|
}, [resolvedPubkey, eventStore]);
|
|
|
|
// Get mailbox relays (kind 10002) - will update when fresh data arrives
|
|
const mailboxEvent = use$(
|
|
() =>
|
|
resolvedPubkey
|
|
? eventStore.replaceable(kinds.RelayList, resolvedPubkey, "")
|
|
: undefined,
|
|
[eventStore, resolvedPubkey],
|
|
);
|
|
const inboxRelays =
|
|
mailboxEvent && mailboxEvent.tags ? getInboxes(mailboxEvent) : [];
|
|
const outboxRelays =
|
|
mailboxEvent && mailboxEvent.tags ? getOutboxes(mailboxEvent) : [];
|
|
|
|
// Get profile metadata event (kind 0)
|
|
const profileEvent = use$(
|
|
() =>
|
|
resolvedPubkey
|
|
? eventStore.replaceable(0, resolvedPubkey, "")
|
|
: undefined,
|
|
[eventStore, resolvedPubkey],
|
|
);
|
|
|
|
// Combine all relays (inbox + outbox) for nprofile
|
|
const allRelays = [...new Set([...inboxRelays, ...outboxRelays])];
|
|
|
|
// Calculate connection count for relay dropdown
|
|
const connectedCount = allRelays.filter(
|
|
(url) => relayStates[url]?.connectionState === "connected",
|
|
).length;
|
|
|
|
// Generate npub or nprofile depending on relay availability
|
|
const identifier =
|
|
resolvedPubkey && allRelays.length > 0
|
|
? nip19.nprofileEncode({
|
|
pubkey: resolvedPubkey,
|
|
relays: allRelays,
|
|
})
|
|
: resolvedPubkey
|
|
? nip19.npubEncode(resolvedPubkey)
|
|
: "";
|
|
|
|
if (pubkey === "$me" && !accountPubkey) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
|
|
<div className="text-muted-foreground">
|
|
<UserIcon className="size-12 mx-auto mb-3" />
|
|
<h3 className="text-lg font-semibold mb-2">Account Required</h3>
|
|
<p className="text-sm max-w-md">
|
|
The <code className="bg-muted px-1.5 py-0.5">$me</code> alias
|
|
requires an active account. Please log in to view your profile.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!resolvedPubkey) {
|
|
return (
|
|
<div className="p-4 text-muted-foreground">Invalid profile pubkey.</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* Compact Header - Single Line */}
|
|
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
|
|
{/* Left: npub/nprofile */}
|
|
<button
|
|
onClick={() => copy(identifier)}
|
|
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
|
|
title={identifier}
|
|
aria-label="Copy profile ID"
|
|
>
|
|
{copied ? (
|
|
<CopyCheck className="size-3 flex-shrink-0" />
|
|
) : (
|
|
<Copy className="size-3 flex-shrink-0" />
|
|
)}
|
|
<code className="truncate">
|
|
{identifier.slice(0, 16)}...{identifier.slice(-8)}
|
|
</code>
|
|
</button>
|
|
|
|
{/* Right: Profile icon and Relay dropdown */}
|
|
<div className="flex items-center gap-3 flex-shrink-0">
|
|
<div className="flex items-center gap-1 text-muted-foreground">
|
|
<UserIcon className="size-3" />
|
|
<span>Profile</span>
|
|
</div>
|
|
|
|
{allRelays.length > 0 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label={`${allRelays.length} relay${allRelays.length !== 1 ? "s" : ""}`}
|
|
>
|
|
<Wifi className="size-3" />
|
|
<span>
|
|
{connectedCount}/{allRelays.length}
|
|
</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80">
|
|
{allRelays.map((url) => {
|
|
const state = relayStates[url];
|
|
const connIcon = getConnectionIcon(state);
|
|
const authIcon = getAuthIcon(state);
|
|
const isInbox = inboxRelays.includes(url);
|
|
const isOutbox = outboxRelays.includes(url);
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
key={url}
|
|
className="flex items-center justify-between gap-2"
|
|
>
|
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
|
{isInbox && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Inbox className="size-3 text-muted-foreground flex-shrink-0" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Inbox</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{isOutbox && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Send className="size-3 text-muted-foreground flex-shrink-0" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Outbox</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<RelayLink
|
|
url={url}
|
|
showInboxOutbox={false}
|
|
className="flex-1 min-w-0 hover:bg-transparent"
|
|
iconClassname="size-3"
|
|
urlClassname="text-xs"
|
|
/>
|
|
</div>
|
|
<div
|
|
className="flex items-center gap-1.5 flex-shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{authIcon && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="cursor-help">{authIcon.icon}</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{authIcon.label}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="cursor-help">{connIcon.icon}</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{connIcon.label}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Profile Content */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{!profile && !profileEvent && <ProfileCardSkeleton variant="full" />}
|
|
|
|
{!profile && profileEvent && (
|
|
<div className="text-center text-muted-foreground text-sm">
|
|
No profile metadata found
|
|
</div>
|
|
)}
|
|
|
|
{profile && (
|
|
<div className="flex flex-col gap-4 max-w-2xl">
|
|
<div className="flex flex-col gap-0">
|
|
{/* Display Name */}
|
|
<UserName
|
|
pubkey={pubkey}
|
|
className="text-2xl font-bold pointer-events-none"
|
|
/>
|
|
{/* NIP-05 */}
|
|
{profile.nip05 && (
|
|
<div className="text-xs">
|
|
<Nip05 pubkey={pubkey} profile={profile} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* About/Bio */}
|
|
{profile.about && (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
About
|
|
</div>
|
|
<RichText
|
|
className="text-sm whitespace-pre-wrap break-words"
|
|
content={profile.about}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Website */}
|
|
{profile.website && (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
Website
|
|
</div>
|
|
<a
|
|
href={profile.website}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-accent underline decoration-dotted"
|
|
>
|
|
{profile.website}
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{/* Lightning Address */}
|
|
{profile.lud16 && (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
Lightning Address
|
|
</div>
|
|
<code className="text-sm font-mono">{profile.lud16}</code>
|
|
</div>
|
|
)}
|
|
|
|
{/* LUD06 (LNURL) */}
|
|
{profile.lud06 && (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
LNURL
|
|
</div>
|
|
<code className="text-sm font-mono break-all">
|
|
{profile.lud06}
|
|
</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|