mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
648 lines
19 KiB
TypeScript
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,
|
|
]);
|
|
}
|