mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
* fix: build proper q-tag with relay hint and author pubkey for replies When sending replies in NIP-29 and NIP-C7 adapters, now build the full q-tag format per NIP-C7 spec: ["q", eventId, relayUrl, pubkey] Previously only the event ID was included, making it harder for clients to fetch the referenced event. Now: - NIP-29: includes group relay URL and author pubkey - NIP-C7: includes seen relay hint and author pubkey https://claude.ai/code/session_01Jy51Ayk57fzaFuuFFm1j1K * chore: remove unused NIP-C7 adapter The NIP-C7 adapter was already disabled/commented out everywhere. Removing the file to reduce dead code. https://claude.ai/code/session_01Jy51Ayk57fzaFuuFFm1j1K * chore: remove NIP-C7 references from docs and code - Remove nip-c7 from ChatProtocol type - Remove commented NIP-C7 adapter imports and switch cases - Update comments to reference NIP-29 instead of NIP-C7 - Update kind 9 renderer docs to reference NIP-29 - Clean up chat-parser docs and error messages https://claude.ai/code/session_01Jy51Ayk57fzaFuuFFm1j1K --------- Co-authored-by: Claude <noreply@anthropic.com>
882 lines
26 KiB
TypeScript
882 lines
26 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 { getSemanticAuthor } from "@/lib/semantic-author";
|
|
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 (
|
|
<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"] : [];
|
|
|
|
// 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<string | null>(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-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,
|
|
]);
|
|
}
|