feat: dynamic titles, event footer

This commit is contained in:
Alejandro Gómez
2025-12-11 22:50:09 +01:00
parent b7fbaf5e46
commit 08db0eff5a
9 changed files with 497 additions and 25 deletions

View File

@@ -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,
]);
}

View File

@@ -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) {
</code>
</button>
{/* Right: Kind Badge, Relay Count, and JSON Toggle */}
{/* Right: Relay Count and JSON Toggle */}
<div className="flex items-center gap-3 flex-shrink-0">
<div className="flex items-center gap-1">
<KindBadge kind={event.kind} variant="compact" />
<span className="text-xs text-muted-foreground">
<KindBadge
kind={event.kind}
showName
showKindNumber={false}
showIcon={false}
/>
</span>
</div>
{relays && relays.length > 0 && (
<button
onClick={() => setShowRelays(!showRelays)}

View File

@@ -0,0 +1,88 @@
import { NostrEvent } from "@/types/nostr";
import { KindBadge } from "./KindBadge";
import { Wifi } from "lucide-react";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { useGrimoire } from "@/core/state";
import { getKindName } from "@/constants/kinds";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { RelayLink } from "./nostr/RelayLink";
interface EventFooterProps {
event: NostrEvent;
}
/**
* EventFooter - Subtle footer for events showing kind and relay information
* Left: Kind badge (clickable to open KIND command)
* Right: Relay count dropdown
*/
export function EventFooter({ event }: EventFooterProps) {
const { addWindow } = useGrimoire();
// Get relays this event was seen on
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : [];
const kindName = getKindName(event.kind);
const handleKindClick = () => {
// Open KIND command to show NIP documentation for this kind
addWindow("kind", { number: event.kind }, `KIND ${event.kind}`);
};
return (
<div className="pt-2">
{/* Footer Bar */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
{/* Left: Kind Badge */}
<button
onClick={handleKindClick}
className="group flex items-center gap-1.5 cursor-crosshair hover:text-foreground transition-colors"
title={`View documentation for kind ${event.kind}`}
>
<KindBadge
kind={event.kind}
variant="compact"
iconClassname="text-muted-foreground group-hover:text-foreground transition-colors size-3"
/>
<span className="text-[10px] leading-[10px]">{kindName}</span>
</button>
{/* Right: Relay Dropdown */}
{relays.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-1 cursor-pointer hover:text-foreground transition-colors"
title={`Seen on ${relays.length} relay${relays.length > 1 ? "s" : ""}`}
>
<Wifi className="size-3" />
<span className="text-[10px] leading-[10px]">
{relays.length}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-64 overflow-y-auto p-1"
>
<DropdownMenuLabel>Seen on</DropdownMenuLabel>
{relays.map((relay) => (
<RelayLink
key={relay}
url={relay}
showInboxOutbox={false}
className="px-2 py-1 rounded-sm"
/>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="mosaic-window-toolbar draggable flex items-center justify-between w-full">
<div className="mosaic-window-title flex items-center gap-2 flex-1">
{Icon && (
<span title={tooltip} className="flex-shrink-0">
<Icon className="size-4 text-muted-foreground" />
</span>
)}
<span className="truncate">{title}</span>
</div>
<WindowToolbar onClose={() => onClose(id)} />
</div>
);
};
return (
<MosaicWindow
path={path}
title={window.title}
toolbarControls={<WindowToolbar onClose={() => onClose(id)} />}
title={title}
renderToolbar={renderToolbar}
>
<WindowRenderer window={window} onClose={() => onClose(id)} />
</MosaicWindow>

View File

@@ -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 (
<div className="flex items-center gap-1">
<>
{onClose && (
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
<button
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={onClose}
title="Close window"
>
<X className="size-3" />
</Button>
<X className="size-4" />
</button>
)}
</div>
</>
);
}

View File

@@ -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({
<EventMenu event={event} />
</div>
{children}
<EventFooter event={event} />
</div>
);
}

View File

@@ -51,7 +51,6 @@ function DefaultKindRenderer({ event }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
<div className="text-sm text-muted-foreground">
<div className="text-xs mb-1">Kind {event.kind} event</div>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words">
{event.content || "(empty content)"}
</pre>

View File

@@ -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<string, CommandIcon> = {
// 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 || "";
}

View File

@@ -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"