Files
grimoire/src/components/DynamicWindowTitle.tsx
Alejandro Gómez 2987a37e65 feat: spells
2025-12-20 14:25:40 +01:00

648 lines
19 KiB
TypeScript

import { ReactElement, useMemo } from "react";
import { WindowInstance } from "@/types/app";
import { useProfile } from "@/hooks/useProfile";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useRelayState } from "@/hooks/useRelayState";
import { useGrimoire } from "@/core/state";
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";
import { nip19 } from "nostr-tools";
import { ProfileContent } from "applesauce-core/helpers";
import {
formatEventIds,
formatDTags,
formatTimeRangeCompact,
formatGenericTag,
} from "@/lib/filter-formatters";
import { getEventDisplayTitle } from "@/lib/event-title";
import { UserName } from "./nostr/UserName";
import { getTagValues } from "@/lib/nostr-utils";
import { getLiveHost } from "@/lib/live-activity";
import type { NostrEvent } from "@/types/nostr";
import { getZapSender } from "applesauce-core/helpers/zap";
export interface WindowTitleData {
title: string | ReactElement;
icon?: LucideIcon;
tooltip?: string;
}
/**
* Get the semantic author of an event based on kind-specific logic
* Returns the pubkey that should be displayed as the "author" for UI purposes
*
* Examples:
* - Zaps (9735): Returns the zapper (P tag), not the lightning service pubkey
* - Live activities (30311): Returns the host (first p tag with "Host" role)
* - Regular events: Returns event.pubkey
*/
function getSemanticAuthor(event: NostrEvent): string {
switch (event.kind) {
case 9735: {
// Zap: show the zapper, not the lightning service pubkey
const zapSender = getZapSender(event);
return zapSender || event.pubkey;
}
case 30311: {
// Live activity: show the host
return getLiveHost(event);
}
default:
return event.pubkey;
}
}
/**
* Format profile names with prefix, handling $me and $contacts aliases
* @param prefix - Prefix to use (e.g., 'by ', '@ ')
* @param pubkeys - Array of pubkeys to format (may include $me or $contacts)
* @param profiles - Array of corresponding profile metadata
* @param accountProfile - Profile of active account for $me resolution
* @param contactsCount - Number of contacts for $contacts display
* @returns Formatted string like "by Alice, Bob & 3 others" or null if no pubkeys
*/
function formatProfileNames(
prefix: string,
pubkeys: string[],
profiles: (ProfileContent | undefined)[],
accountProfile?: ProfileContent,
contactsCount?: number,
): string | null {
if (!pubkeys.length) return null;
const names: string[] = [];
let processedCount = 0;
// Process first two pubkeys (may be aliases or real pubkeys)
for (let i = 0; i < Math.min(2, pubkeys.length); i++) {
const pubkey = pubkeys[i];
const profile = profiles[i];
if (pubkey === "$me") {
// Show account's name or "You"
if (accountProfile) {
const name =
accountProfile.display_name || accountProfile.name || "You";
names.push(name);
} else {
names.push("You");
}
processedCount++;
} else if (pubkey === "$contacts") {
// Show "Your Contacts" with count
if (contactsCount !== undefined && contactsCount > 0) {
names.push(`Your Contacts (${contactsCount})`);
} else {
names.push("Your Contacts");
}
processedCount++;
} else {
// Regular pubkey
if (profile) {
const name = profile.display_name || profile.name;
names.push(name || `${pubkey.slice(0, 8)}...`);
} else {
names.push(`${pubkey.slice(0, 8)}...`);
}
processedCount++;
}
}
// Add "& X more" if more than 2
if (pubkeys.length > 2) {
const othersCount = pubkeys.length - 2;
names.push(`& ${othersCount} more`);
}
return names.length > 0 ? `${prefix}${names.join(", ")}` : null;
}
/**
* Format hashtags with prefix
* @param prefix - Prefix to use (e.g., '#')
* @param hashtags - Array of hashtag strings
* @returns Formatted string like "#bitcoin, #nostr & 2 others" or null if no hashtags
*/
function formatHashtags(prefix: string, hashtags: string[]): string | null {
if (!hashtags.length) return null;
const formatted: string[] = [];
const [tag1, tag2] = hashtags;
// Add first two hashtags
if (tag1) formatted.push(`${prefix}${tag1}`);
if (hashtags.length > 1 && tag2) formatted.push(`${prefix}${tag2}`);
// Add "& X more" if more than 2
if (hashtags.length > 2) {
const moreCount = hashtags.length - 2;
formatted.push(`& ${moreCount} more`);
}
return formatted.join(", ");
}
/**
* Generate raw command string from window appId and props
*/
function generateRawCommand(appId: string, props: any): string {
switch (appId) {
case "profile":
if (props.pubkey) {
try {
const npub = nip19.npubEncode(props.pubkey);
return `profile ${npub}`;
} catch {
return `profile ${props.pubkey.slice(0, 16)}...`;
}
}
return "profile";
case "kind":
return props.number ? `kind ${props.number}` : "kind";
case "nip":
return props.number ? `nip ${props.number}` : "nip";
case "relay":
return props.url ? `relay ${props.url}` : "relay";
case "open":
if (props.pointer) {
try {
if ("id" in props.pointer) {
const nevent = nip19.neventEncode({ id: props.pointer.id });
return `open ${nevent}`;
} else if ("kind" in props.pointer && "pubkey" in props.pointer) {
const naddr = nip19.naddrEncode({
kind: props.pointer.kind,
pubkey: props.pointer.pubkey,
identifier: props.pointer.identifier || "",
});
return `open ${naddr}`;
}
} catch {
// Fallback to shortened ID
}
}
return "open";
case "encode":
if (props.args && props.args[0]) {
return `encode ${props.args[0]}`;
}
return "encode";
case "decode":
if (props.args && props.args[0]) {
return `decode ${props.args[0]}`;
}
return "decode";
case "req":
// REQ command can be complex, show simplified version
if (props.filter) {
const parts: string[] = ["req"];
if (props.filter.kinds?.length) {
parts.push(`-k ${props.filter.kinds.join(",")}`);
}
if (props.filter["#t"]?.length) {
parts.push(`-t ${props.filter["#t"].slice(0, 2).join(",")}`);
}
if (props.filter.authors?.length) {
// Keep original aliases in tooltip for clarity
const authorDisplay = props.filter.authors.slice(0, 2).join(",");
parts.push(`-a ${authorDisplay}`);
}
if (props.filter["#p"]?.length) {
// Keep original aliases in tooltip for clarity
const pTagDisplay = props.filter["#p"].slice(0, 2).join(",");
parts.push(`-p ${pTagDisplay}`);
}
if (props.filter["#P"]?.length) {
// Keep original aliases in tooltip for clarity
const pTagUpperDisplay = props.filter["#P"].slice(0, 2).join(",");
parts.push(`-P ${pTagUpperDisplay}`);
}
return parts.join(" ");
}
return "req";
case "man":
return props.cmd ? `man ${props.cmd}` : "man";
case "spells":
return "spells";
default:
return appId;
}
}
/**
* 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, customTitle } = window;
// Get relay state for conn viewer
const { relays } = useRelayState();
// Get account state for alias resolution
const { state } = useGrimoire();
const activeAccount = state.activeAccount;
const accountPubkey = activeAccount?.pubkey;
// Fetch account profile for $me display
const accountProfile = useProfile(accountPubkey || "");
// Fetch contact list for $contacts display
const contactListEvent = useNostrEvent(
accountPubkey
? { kind: 3, pubkey: accountPubkey, identifier: "" }
: undefined,
);
// Extract contacts count from kind 3 event
const contactsCount = contactListEvent
? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64)
.length
: 0;
// Profile titles
const profilePubkey = appId === "profile" ? props.pubkey : null;
const profile = useProfile(profilePubkey || "");
const profileTitle = useMemo(() => {
if (appId !== "profile" || !profilePubkey) return null;
if (profile) {
return profile.display_name || profile.name;
}
return `Profile ${profilePubkey.slice(0, 8)}...`;
}, [appId, profilePubkey, profile]);
// Event titles - use unified title extraction
const eventPointer: EventPointer | AddressPointer | undefined =
appId === "open" ? props.pointer : undefined;
const event = useNostrEvent(eventPointer);
// Get semantic author for events (e.g., zapper for zaps, host for live activities)
const semanticAuthorPubkey = useMemo(() => {
if (appId !== "open" || !event) return null;
return getSemanticAuthor(event);
}, [appId, event]);
// Fetch semantic author profile to ensure it's cached for rendering
// Called for side effects (preloading profile data)
void useProfile(semanticAuthorPubkey || "");
const eventTitle = useMemo(() => {
if (appId !== "open" || !event) return null;
return (
<div className="flex items-center gap-1">
<div className="flex items-center gap-0">
{getKindName(event.kind)}
<span>:</span>
</div>
{getEventDisplayTitle(event, false)}
<span> - </span>
<UserName
pubkey={semanticAuthorPubkey || event.pubkey}
className="text-inherit"
/>
</div>
);
}, [appId, event, semanticAuthorPubkey]);
// 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]);
// Fetch profiles for REQ authors and tagged users (up to 2 each)
const reqAuthors =
appId === "req" && props.filter?.authors ? props.filter.authors : [];
const [author1Pubkey, author2Pubkey] = reqAuthors;
const author1Profile = useProfile(author1Pubkey);
const author2Profile = useProfile(author2Pubkey);
const reqTagged =
appId === "req" && props.filter?.["#p"] ? props.filter["#p"] : [];
const [tagged1Pubkey, tagged2Pubkey] = reqTagged;
const tagged1Profile = useProfile(tagged1Pubkey);
const tagged2Profile = useProfile(tagged2Pubkey);
const reqTaggedUppercase =
appId === "req" && props.filter?.["#P"] ? props.filter["#P"] : [];
const [taggedUpper1Pubkey, taggedUpper2Pubkey] = reqTaggedUppercase;
const taggedUpper1Profile = useProfile(taggedUpper1Pubkey);
const taggedUpper2Profile = useProfile(taggedUpper2Pubkey);
const reqHashtags =
appId === "req" && props.filter?.["#t"] ? props.filter["#t"] : [];
// REQ titles
const reqTitle = useMemo(() => {
if (appId !== "req") return null;
const { filter } = props;
// Generate a descriptive title from the filter
const parts: string[] = [];
// 1. Kinds
if (filter.kinds && filter.kinds.length > 0) {
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}`,
);
}
}
// 2. Hashtags (#t)
if (filter["#t"] && filter["#t"].length > 0) {
const hashtagText = formatHashtags("#", reqHashtags);
if (hashtagText) parts.push(hashtagText);
}
// 3. Mentions (#p)
if (filter["#p"] && filter["#p"].length > 0) {
const taggedText = formatProfileNames(
"@",
reqTagged,
[tagged1Profile, tagged2Profile],
accountProfile,
contactsCount,
);
if (taggedText) parts.push(taggedText);
}
// 3b. Zap Senders (#P)
if (filter["#P"] && filter["#P"].length > 0) {
const zapSendersText = formatProfileNames(
"⚡ from ",
reqTaggedUppercase,
[taggedUpper1Profile, taggedUpper2Profile],
accountProfile,
contactsCount,
);
if (zapSendersText) parts.push(zapSendersText);
}
// 4. Event References (#e) - NEW
if (filter["#e"] && filter["#e"].length > 0) {
const eventIdsText = formatEventIds(filter["#e"], 2);
if (eventIdsText) parts.push(`${eventIdsText}`);
}
// 5. D-Tags (#d) - NEW
if (filter["#d"] && filter["#d"].length > 0) {
const dTagsText = formatDTags(filter["#d"], 2);
if (dTagsText) parts.push(`📝 ${dTagsText}`);
}
// 6. Authors
if (filter.authors && filter.authors.length > 0) {
const authorsText = formatProfileNames(
"by ",
reqAuthors,
[author1Profile, author2Profile],
accountProfile,
contactsCount,
);
if (authorsText) parts.push(authorsText);
}
// 7. Time Range - NEW
if (filter.since || filter.until) {
const timeRangeText = formatTimeRangeCompact(filter.since, filter.until);
if (timeRangeText) parts.push(`📅 ${timeRangeText}`);
}
// 8. Generic Tags - NEW (a-z, A-Z filters excluding e, p, P, t, d)
const genericTags = Object.entries(filter)
.filter(
([key]) =>
key.startsWith("#") &&
key.length === 2 &&
!["#e", "#p", "#P", "#t", "#d"].includes(key),
)
.map(([key, values]) => ({ letter: key[1], values: values as string[] }));
if (genericTags.length > 0) {
genericTags.slice(0, 2).forEach((tag) => {
const tagText = formatGenericTag(tag.letter, tag.values, 1);
if (tagText) parts.push(tagText);
});
if (genericTags.length > 2) {
parts.push(`+${genericTags.length - 2} more tags`);
}
}
return parts.length > 0 ? parts.join(" • ") : "REQ";
}, [
appId,
props,
reqAuthors,
reqTagged,
reqTaggedUppercase,
reqHashtags,
author1Profile,
author2Profile,
tagged1Profile,
tagged2Profile,
taggedUpper1Profile,
taggedUpper2Profile,
accountProfile,
contactsCount,
]);
// 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 - show command name first, then description
const manTitle = useMemo(() => {
if (appId !== "man") return null;
const cmdName = props.cmd?.toUpperCase() || "MAN";
const description = getCommandDescription(props.cmd);
return description ? `${cmdName} - ${description}` : cmdName;
}, [appId, props]);
// 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]);
// Conn viewer title with connection count
const connTitle = useMemo(() => {
if (appId !== "conn") return null;
const relayList = Object.values(relays);
const connectedCount = relayList.filter(
(r) => r.connectionState === "connected",
).length;
return `Relay Pool (${connectedCount}/${relayList.length})`;
}, [appId, relays]);
// Generate final title data with icon and tooltip
return useMemo(() => {
let title: ReactElement | string;
let icon: LucideIcon | undefined;
let tooltip: string | undefined;
// Generate raw command for tooltip
const rawCommand = generateRawCommand(appId, props);
// Priority 0: Custom title always wins (user override via --title flag)
if (customTitle) {
title = customTitle;
icon = getCommandIcon(appId);
tooltip = rawCommand;
return { title, icon, tooltip };
}
// Priority order for title selection (dynamic titles based on data)
if (profileTitle) {
title = profileTitle;
icon = getCommandIcon("profile");
tooltip = rawCommand;
} 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);
} else {
icon = getCommandIcon("open");
}
tooltip = rawCommand;
} else if (kindTitle && appId === "kind") {
title = kindTitle;
const kindNum = parseInt(props.number);
icon = getKindIcon(kindNum);
tooltip = rawCommand;
} else if (relayTitle) {
title = relayTitle;
icon = getCommandIcon("relay");
tooltip = rawCommand;
} else if (reqTitle) {
title = reqTitle;
icon = getCommandIcon("req");
tooltip = rawCommand;
} else if (encodeTitle) {
title = encodeTitle;
icon = getCommandIcon("encode");
tooltip = rawCommand;
} else if (decodeTitle) {
title = decodeTitle;
icon = getCommandIcon("decode");
tooltip = rawCommand;
} else if (nipTitle) {
title = nipTitle;
icon = getCommandIcon("nip");
tooltip = rawCommand;
} else if (manTitle) {
title = manTitle;
// Use the specific command's icon, not the generic "man" icon
icon = getCommandIcon(props.cmd);
tooltip = rawCommand;
} else if (kindsTitle) {
title = kindsTitle;
icon = getCommandIcon("kinds");
tooltip = rawCommand;
} else if (debugTitle) {
title = debugTitle;
icon = getCommandIcon("debug");
tooltip = rawCommand;
} else if (connTitle) {
title = connTitle;
icon = getCommandIcon("conn");
tooltip = rawCommand;
} else {
title = staticTitle || appId.toUpperCase();
tooltip = rawCommand;
}
return { title, icon, tooltip };
}, [
appId,
props,
event,
customTitle,
profileTitle,
eventTitle,
kindTitle,
relayTitle,
reqTitle,
encodeTitle,
decodeTitle,
nipTitle,
manTitle,
kindsTitle,
debugTitle,
connTitle,
staticTitle,
]);
}