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 (