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 { getSemanticAuthor } from "@/lib/semantic-author"; // import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; import type { ChatProtocol, ProtocolIdentifier } from "@/types/chat"; import { useState, useEffect } from "react"; export interface WindowTitleData { title: string | ReactElement; icon?: LucideIcon; tooltip?: string; } /** * 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 "count": // COUNT command - human-readable summary if (props.filter) { const parts: string[] = []; // Kinds - use human-readable names if (props.filter.kinds?.length) { if (props.filter.kinds.length === 1) { parts.push(getKindName(props.filter.kinds[0])); } else if (props.filter.kinds.length <= 3) { parts.push(props.filter.kinds.map(getKindName).join(", ")); } else { parts.push(`${props.filter.kinds.length} kinds`); } } // Authors if (props.filter.authors?.length) { const count = props.filter.authors.length; if (count === 1) { const pk = props.filter.authors[0]; parts.push(`by ${pk.slice(0, 8)}...`); } else { parts.push(`by ${count} authors`); } } // Mentions (#p tags) if (props.filter["#p"]?.length) { const count = props.filter["#p"].length; if (count === 1) { const pk = props.filter["#p"][0]; parts.push(`@${pk.slice(0, 8)}...`); } else { parts.push(`@${count} users`); } } // Hashtags if (props.filter["#t"]?.length) { const tags = props.filter["#t"]; if (tags.length <= 2) { parts.push(tags.map((t: string) => `#${t}`).join(" ")); } else { parts.push(`#${tags[0]} +${tags.length - 1}`); } } // Search if (props.filter.search) { parts.push(`"${props.filter.search}"`); } if (parts.length > 0) { return `count: ${parts.join(" ")}`; } } return "count"; case "man": return props.cmd ? `man ${props.cmd}` : "man"; case "spells": return "spells"; case "zap": if (props.recipientPubkey) { try { const npub = nip19.npubEncode(props.recipientPubkey); let result = `zap ${npub}`; if (props.eventPointer) { if ("id" in props.eventPointer) { const nevent = nip19.neventEncode({ id: props.eventPointer.id }); result += ` ${nevent}`; } else if ( "kind" in props.eventPointer && "pubkey" in props.eventPointer ) { const naddr = nip19.naddrEncode({ kind: props.eventPointer.kind, pubkey: props.eventPointer.pubkey, identifier: props.eventPointer.identifier || "", }); result += ` ${naddr}`; } } return result; } catch { return `zap ${props.recipientPubkey.slice(0, 16)}...`; } } return "zap"; 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 (
{getKindName(event.kind)} :
{getEventDisplayTitle(event, false)} -
); }, [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"] : []; // Fetch profiles for COUNT authors and tagged users (up to 2 each) const countAuthors = appId === "count" && props.filter?.authors ? props.filter.authors : []; const [countAuthor1Pubkey, countAuthor2Pubkey] = countAuthors; const countAuthor1Profile = useProfile(countAuthor1Pubkey); const countAuthor2Profile = useProfile(countAuthor2Pubkey); const countTagged = appId === "count" && props.filter?.["#p"] ? props.filter["#p"] : []; const [countTagged1Pubkey, countTagged2Pubkey] = countTagged; const countTagged1Profile = useProfile(countTagged1Pubkey); const countTagged2Profile = useProfile(countTagged2Pubkey); const countHashtags = appId === "count" && props.filter?.["#t"] ? props.filter["#t"] : []; // Zap titles - load event to derive recipient if needed const zapEventPointer: EventPointer | AddressPointer | undefined = appId === "zap" ? props.eventPointer : undefined; const zapEvent = useNostrEvent(zapEventPointer); // Derive recipient: use explicit pubkey or semantic author from event const zapRecipientPubkey = useMemo(() => { if (appId !== "zap") return null; // If explicit recipient provided, use it if (props.recipientPubkey) return props.recipientPubkey; // Otherwise derive from event's semantic author if (zapEvent) return getSemanticAuthor(zapEvent); return null; }, [appId, props.recipientPubkey, zapEvent]); const zapRecipientProfile = useProfile(zapRecipientPubkey || ""); const zapTitle = useMemo(() => { if (appId !== "zap" || !zapRecipientPubkey) return null; if (zapRecipientProfile) { const name = zapRecipientProfile.display_name || zapRecipientProfile.name || `${zapRecipientPubkey.slice(0, 8)}...`; return `Zap ${name}`; } return `Zap ${zapRecipientPubkey.slice(0, 8)}...`; }, [appId, zapRecipientPubkey, zapRecipientProfile]); // 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, ]); // COUNT titles const countTitle = useMemo(() => { if (appId !== "count") return null; const { filter } = props; if (!filter) return "COUNT"; // 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("#", countHashtags); if (hashtagText) parts.push(hashtagText); } // 3. Mentions (#p) if (filter["#p"] && filter["#p"].length > 0) { const taggedText = formatProfileNames( "@", countTagged, [countTagged1Profile, countTagged2Profile], accountProfile, contactsCount, ); if (taggedText) parts.push(taggedText); } // 4. Authors if (filter.authors && filter.authors.length > 0) { const authorsText = formatProfileNames( "by ", countAuthors, [countAuthor1Profile, countAuthor2Profile], accountProfile, contactsCount, ); if (authorsText) parts.push(authorsText); } // 5. Search if (filter.search) { parts.push(`"${filter.search}"`); } return parts.length > 0 ? parts.join(" • ") : "COUNT"; }, [ appId, props, countAuthors, countTagged, countHashtags, countAuthor1Profile, countAuthor2Profile, countTagged1Profile, countTagged2Profile, 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]); // Chat viewer title - resolve conversation to get partner name const [chatTitle, setChatTitle] = useState(null); useEffect(() => { if (appId !== "chat") { setChatTitle(null); return; } const protocol = props.protocol as ChatProtocol; const identifier = props.identifier as ProtocolIdentifier; // Get adapter and resolve conversation // Currently only NIP-29 is supported const getAdapter = () => { switch (protocol) { // case "nip-c7": // Coming soon // return new NipC7Adapter(); case "nip-29": return new Nip29Adapter(); default: return null; } }; const adapter = getAdapter(); if (!adapter) { setChatTitle("Chat"); return; } // Resolve conversation asynchronously adapter .resolveConversation(identifier) .then((conversation) => { setChatTitle(conversation.title); }) .catch(() => { setChatTitle("Chat"); }); }, [appId, props]); // 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 (zapTitle) { title = zapTitle; icon = getCommandIcon("zap"); tooltip = rawCommand; } else 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 (countTitle) { title = countTitle; icon = getCommandIcon("count"); 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 if (chatTitle && appId === "chat") { title = chatTitle; icon = getCommandIcon("chat"); tooltip = rawCommand; } else { title = staticTitle || appId.toUpperCase(); tooltip = rawCommand; } return { title, icon, tooltip }; }, [ appId, props, event, customTitle, zapTitle, profileTitle, eventTitle, kindTitle, relayTitle, reqTitle, countTitle, encodeTitle, decodeTitle, nipTitle, manTitle, kindsTitle, debugTitle, connTitle, chatTitle, staticTitle, ]); }