mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
* fix: use semantic author for zap targeting
When zapping certain event kinds (zaps, streams), use the semantic
author instead of event.pubkey:
- Zaps (9735): Target the zapper, not the lightning service
- Streams (30311): Target the host, not the event publisher
Changes:
- Extract getSemanticAuthor() to shared utility (src/lib/semantic-author.ts)
- Update BaseEventRenderer to use semantic author when opening zap dialog
- Update ZapWindow to resolve recipient using semantic author
- Refactor DynamicWindowTitle to use shared utility
This ensures that when you zap an event, you're zapping the right person
(the one who semantically "owns" or created the event), not just whoever
signed it.
* fix: load event in DynamicWindowTitle to derive zap recipient
When opening a zap dialog via 'zap naddr1...' or 'zap nevent1...', the
window title was showing "ZAP" instead of "Zap {host name}" because
DynamicWindowTitle only had access to the empty recipientPubkey from
the initial props.
Now DynamicWindowTitle:
- Loads the event from eventPointer if present
- Derives the recipient using getSemanticAuthor() if recipientPubkey is empty
- Falls back to explicit recipientPubkey if provided
This ensures the window title shows the correct recipient name
immediately, matching the behavior in the ZapWindow component itself.
---------
Co-authored-by: Claude <noreply@anthropic.com>
885 lines
26 KiB
TypeScript
885 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 { 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 (
|
|
<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-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,
|
|
]);
|
|
}
|