diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx new file mode 100644 index 0000000..2a9e645 --- /dev/null +++ b/src/components/DynamicWindowTitle.tsx @@ -0,0 +1,274 @@ +import { useMemo } from "react"; +import { WindowInstance } from "@/types/app"; +import { useProfile } from "@/hooks/useProfile"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { getKindName, getKindIcon } from "@/constants/kinds"; +import { getNipTitle } from "@/constants/nips"; +import { getCommandIcon, getCommandDescription } from "@/constants/command-icons"; +import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; +import type { LucideIcon } from "lucide-react"; + +export interface WindowTitleData { + title: string; + icon?: LucideIcon; + tooltip?: string; +} + +/** + * useDynamicWindowTitle - Hook to generate dynamic window titles based on loaded data + * Similar to WindowRenderer but for titles instead of content + */ +export function useDynamicWindowTitle(window: WindowInstance): WindowTitleData { + return useDynamicTitle(window); +} + +function useDynamicTitle(window: WindowInstance): WindowTitleData { + const { appId, props, title: staticTitle } = window; + + // Profile titles + const profilePubkey = appId === "profile" ? props.pubkey : null; + const profile = useProfile(profilePubkey || ""); + const profileTitle = useMemo(() => { + if (appId !== "profile" || !profilePubkey) return null; + + if (profile) { + const displayName = profile.display_name || profile.name; + if (displayName) { + return `@${displayName}`; + } + } + + return `Profile ${profilePubkey.slice(0, 8)}...`; + }, [appId, profilePubkey, profile]); + + // Event titles + const eventPointer: EventPointer | AddressPointer | undefined = + appId === "open" ? props.pointer : undefined; + const event = useNostrEvent(eventPointer); + const eventTitle = useMemo(() => { + if (appId !== "open" || !event) return null; + + const kindName = getKindName(event.kind); + + // For text-based events, show a preview + if (event.kind === 1 && event.content) { + const preview = event.content.slice(0, 40).trim(); + return preview ? `${kindName}: ${preview}...` : kindName; + } + + // For articles (kind 30023), show title tag + if (event.kind === 30023) { + const titleTag = event.tags.find((t) => t[0] === "title")?.[1]; + if (titleTag) { + return titleTag.length > 50 + ? `${titleTag.slice(0, 50)}...` + : titleTag; + } + } + + // For highlights (kind 9802), show preview + if (event.kind === 9802 && event.content) { + const preview = event.content.slice(0, 40).trim(); + return preview ? `Highlight: ${preview}...` : "Highlight"; + } + + return kindName; + }, [appId, event]); + + // Kind titles + const kindTitle = useMemo(() => { + if (appId !== "kind") return null; + const kindNum = parseInt(props.number); + return getKindName(kindNum); + }, [appId, props]); + + // Relay titles (clean up URL) + const relayTitle = useMemo(() => { + if (appId !== "relay") return null; + try { + const url = new URL(props.url); + return url.hostname; + } catch { + return props.url; + } + }, [appId, props]); + + // REQ titles + const reqTitle = useMemo(() => { + if (appId !== "req") return null; + const { filter } = props; + + // Generate a descriptive title from the filter + const parts: string[] = []; + + if (filter.kinds && filter.kinds.length > 0) { + // Show actual kind names + const kindNames = filter.kinds.map((k: number) => getKindName(k)); + if (kindNames.length <= 3) { + parts.push(kindNames.join(", ")); + } else { + parts.push(`${kindNames.slice(0, 3).join(", ")}, +${kindNames.length - 3}`); + } + } + + if (filter.authors && filter.authors.length > 0) { + parts.push(`${filter.authors.length} author${filter.authors.length > 1 ? "s" : ""}`); + } + + return parts.length > 0 ? parts.join(" • ") : "REQ"; + }, [appId, props]); + + // Encode/Decode titles + const encodeTitle = useMemo(() => { + if (appId !== "encode") return null; + const { args } = props; + if (args && args[0]) { + return `ENCODE ${args[0].toUpperCase()}`; + } + return "ENCODE"; + }, [appId, props]); + + const decodeTitle = useMemo(() => { + if (appId !== "decode") return null; + const { args } = props; + if (args && args[0]) { + const prefix = args[0].match(/^(npub|nprofile|note|nevent|naddr|nsec)/i)?.[1]; + if (prefix) { + return `DECODE ${prefix.toUpperCase()}`; + } + } + return "DECODE"; + }, [appId, props]); + + // NIP titles + const nipTitle = useMemo(() => { + if (appId !== "nip") return null; + const title = getNipTitle(props.number); + return `NIP-${props.number}: ${title}`; + }, [appId, props]); + + // Man page titles - just show the command description, icon shows on hover + const manTitle = useMemo(() => { + if (appId !== "man") return null; + // For man pages, we'll show the command's description via tooltip + // The title can just be generic or empty, as the icon conveys meaning + return getCommandDescription(props.cmd) || `${props.cmd} manual`; + }, [appId, props]); + + // Feed title + const feedTitle = useMemo(() => { + if (appId !== "feed") return null; + return "Feed"; + }, [appId]); + + // Win viewer title + const winTitle = useMemo(() => { + if (appId !== "win") return null; + return "Windows"; + }, [appId]); + + // Kinds viewer title + const kindsTitle = useMemo(() => { + if (appId !== "kinds") return null; + return "Kinds"; + }, [appId]); + + // Debug viewer title + const debugTitle = useMemo(() => { + if (appId !== "debug") return null; + return "Debug"; + }, [appId]); + + // Generate final title data with icon and tooltip + return useMemo(() => { + let title: string; + let icon: LucideIcon | undefined; + let tooltip: string | undefined; + + // Priority order for title selection + if (profileTitle) { + title = profileTitle; + icon = getCommandIcon("profile"); + tooltip = `profile: ${getCommandDescription("profile")}`; + } else if (eventTitle && appId === "open") { + title = eventTitle; + // Use the event's kind icon if we have the event loaded + if (event) { + icon = getKindIcon(event.kind); + const kindName = getKindName(event.kind); + tooltip = `${kindName} (kind ${event.kind})`; + } else { + icon = getCommandIcon("open"); + tooltip = `open: ${getCommandDescription("open")}`; + } + } else if (kindTitle && appId === "kind") { + title = kindTitle; + const kindNum = parseInt(props.number); + icon = getKindIcon(kindNum); + tooltip = `kind: ${getCommandDescription("kind")}`; + } else if (relayTitle) { + title = relayTitle; + icon = getCommandIcon("relay"); + tooltip = `relay: ${getCommandDescription("relay")}`; + } else if (reqTitle) { + title = reqTitle; + icon = getCommandIcon("req"); + tooltip = `req: ${getCommandDescription("req")}`; + } else if (encodeTitle) { + title = encodeTitle; + icon = getCommandIcon("encode"); + tooltip = `encode: ${getCommandDescription("encode")}`; + } else if (decodeTitle) { + title = decodeTitle; + icon = getCommandIcon("decode"); + tooltip = `decode: ${getCommandDescription("decode")}`; + } else if (nipTitle) { + title = nipTitle; + icon = getCommandIcon("nip"); + tooltip = `nip: ${getCommandDescription("nip")}`; + } else if (manTitle) { + title = manTitle; + // Use the specific command's icon, not the generic "man" icon + icon = getCommandIcon(props.cmd); + tooltip = `${props.cmd}: ${getCommandDescription(props.cmd)}`; + } else if (feedTitle) { + title = feedTitle; + icon = getCommandIcon("feed"); + tooltip = `feed: ${getCommandDescription("feed")}`; + } else if (winTitle) { + title = winTitle; + icon = getCommandIcon("win"); + tooltip = `win: ${getCommandDescription("win")}`; + } else if (kindsTitle) { + title = kindsTitle; + icon = getCommandIcon("kinds"); + tooltip = `kinds: ${getCommandDescription("kinds")}`; + } else if (debugTitle) { + title = debugTitle; + icon = getCommandIcon("debug"); + tooltip = `debug: ${getCommandDescription("debug")}`; + } else { + title = staticTitle; + } + + return { title, icon, tooltip }; + }, [ + appId, + props, + event, + profileTitle, + eventTitle, + kindTitle, + relayTitle, + reqTitle, + encodeTitle, + decodeTitle, + nipTitle, + manTitle, + feedTitle, + winTitle, + kindsTitle, + debugTitle, + staticTitle, + ]); +} diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx index 4415ffd..9b652fe 100644 --- a/src/components/EventDetailViewer.tsx +++ b/src/components/EventDetailViewer.tsx @@ -7,7 +7,6 @@ import { Kind3DetailView } from "./nostr/kinds/Kind3Renderer"; import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer"; import { Kind9802DetailRenderer } from "./nostr/kinds/Kind9802DetailRenderer"; import { Kind10002DetailRenderer } from "./nostr/kinds/Kind10002DetailRenderer"; -import { KindBadge } from "./KindBadge"; import { Copy, Check, @@ -93,19 +92,8 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) { - {/* Right: Kind Badge, Relay Count, and JSON Toggle */} + {/* Right: Relay Count and JSON Toggle */}
-
- - - - -
{relays && relays.length > 0 && ( + + {/* Right: Relay Dropdown */} + {relays.length > 0 && ( + + + + + + Seen on + {relays.map((relay) => ( + + ))} + + + )} +
+ + ); +} diff --git a/src/components/WindowTitle.tsx b/src/components/WindowTitle.tsx index 64a1342..9304314 100644 --- a/src/components/WindowTitle.tsx +++ b/src/components/WindowTitle.tsx @@ -2,6 +2,7 @@ import { MosaicWindow, MosaicBranch } from "react-mosaic-component"; import { WindowInstance } from "@/types/app"; import { WindowToolbar } from "./WindowToolbar"; import { WindowRenderer } from "./WindowRenderer"; +import { useDynamicWindowTitle } from "./DynamicWindowTitle"; interface WindowTileProps { id: string; @@ -11,11 +12,31 @@ interface WindowTileProps { } export function WindowTile({ id, window, path, onClose }: WindowTileProps) { + const { title, icon, tooltip } = useDynamicWindowTitle(window); + const Icon = icon; + + // Custom toolbar renderer to include icon + const renderToolbar = () => { + return ( +
+
+ {Icon && ( + + + + )} + {title} +
+ onClose(id)} /> +
+ ); + }; + return ( onClose(id)} />} + title={title} + renderToolbar={renderToolbar} > onClose(id)} /> diff --git a/src/components/WindowToolbar.tsx b/src/components/WindowToolbar.tsx index e5674b2..05cbf0c 100644 --- a/src/components/WindowToolbar.tsx +++ b/src/components/WindowToolbar.tsx @@ -1,5 +1,4 @@ import { X } from "lucide-react"; -import { Button } from "./ui/button"; interface WindowToolbarProps { onClose?: () => void; @@ -7,17 +6,16 @@ interface WindowToolbarProps { export function WindowToolbar({ onClose }: WindowToolbarProps) { return ( -
+ <> {onClose && ( - + + )} -
+ ); } diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 8dfc564..8598973 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -16,6 +16,7 @@ import { useCopy } from "@/hooks/useCopy"; import { JsonViewer } from "@/components/JsonViewer"; import { formatTimestamp } from "@/hooks/useLocale"; import { nip19 } from "nostr-tools"; +import { EventFooter } from "@/components/EventFooter"; // NIP-01 Kind ranges const REPLACEABLE_START = 10000; @@ -199,6 +200,7 @@ export function BaseEventContainer({ {children} + ); } diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index a0fd4fa..202fb91 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -51,7 +51,6 @@ function DefaultKindRenderer({ event }: BaseEventProps) { return (
-
Kind {event.kind} event
           {event.content || "(empty content)"}
         
diff --git a/src/constants/command-icons.ts b/src/constants/command-icons.ts new file mode 100644 index 0000000..f0a5175 --- /dev/null +++ b/src/constants/command-icons.ts @@ -0,0 +1,100 @@ +import { + Book, + Podcast, + FileText, + HelpCircle, + List, + BookOpen, + ExternalLink, + User, + Lock, + Unlock, + Radio, + Rss, + Layout, + Bug, + type LucideIcon, +} from "lucide-react"; + +/** + * Icon mapping for all commands/apps + * Each command has an icon and optional tooltip description + */ +export interface CommandIcon { + icon: LucideIcon; + description: string; +} + +export const COMMAND_ICONS: Record = { + // Documentation commands + nip: { + icon: Book, + description: "View Nostr Implementation Possibility specification", + }, + kind: { + icon: FileText, + description: "View information about a Nostr event kind", + }, + kinds: { + icon: List, + description: "Display all supported Nostr event kinds", + }, + man: { + icon: BookOpen, + description: "Display manual page for a command", + }, + help: { + icon: HelpCircle, + description: "Display general help information", + }, + + // Nostr commands + req: { + icon: Podcast, + description: "Active subscription to Nostr relays with filters", + }, + open: { + icon: ExternalLink, + description: "Open and view a Nostr event", + }, + profile: { + icon: User, + description: "View a Nostr user profile", + }, + relay: { + icon: Radio, + description: "View relay information and statistics", + }, + feed: { + icon: Rss, + description: "View event feed", + }, + + // Utility commands + encode: { + icon: Lock, + description: "Encode data to NIP-19 format", + }, + decode: { + icon: Unlock, + description: "Decode NIP-19 encoded identifiers", + }, + + // System commands + win: { + icon: Layout, + description: "View all open windows", + }, + debug: { + icon: Bug, + description: "Display application state for debugging", + }, +}; + +export function getCommandIcon(command: string): LucideIcon { + return COMMAND_ICONS[command]?.icon || FileText; +} + +export function getCommandDescription(command: string): string { + return COMMAND_ICONS[command]?.description || ""; +} diff --git a/src/types/app.ts b/src/types/app.ts index f19e803..47499c7 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -2,12 +2,14 @@ import { MosaicNode } from "react-mosaic-component"; export type AppId = | "nip" + //| "nips" | "kind" | "kinds" | "man" | "feed" | "win" | "req" + //| "event" | "open" | "profile" | "encode"