From c21df2b420cbb25cfc35effe99489644580d2053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 14 Dec 2025 00:07:06 +0100 Subject: [PATCH] ui: friendlier req viewer --- src/components/CommandLauncher.tsx | 8 - src/components/DynamicWindowTitle.tsx | 47 ++- src/components/ReqViewer.tsx | 259 +++++++++++----- src/lib/filter-formatters.test.ts | 431 ++++++++++++++++++++++++++ src/lib/filter-formatters.ts | 233 ++++++++++++++ 5 files changed, 895 insertions(+), 83 deletions(-) create mode 100644 src/lib/filter-formatters.test.ts create mode 100644 src/lib/filter-formatters.ts diff --git a/src/components/CommandLauncher.tsx b/src/components/CommandLauncher.tsx index 480d7b4..37043ca 100644 --- a/src/components/CommandLauncher.tsx +++ b/src/components/CommandLauncher.tsx @@ -123,14 +123,6 @@ export default function CommandLauncher({ className="command-input" /> - {recognizedCommand && parsed.args.length > 0 && ( -
- Parsed: - {commandName} - {parsed.args.join(" ")} -
- )} - {commandName diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 718be26..68d1c7b 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -13,6 +13,12 @@ import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; import type { LucideIcon } from "lucide-react"; import { kinds, nip19 } from "nostr-tools"; import { ProfileContent } from "applesauce-core/helpers"; +import { + formatEventIds, + formatDTags, + formatTimeRangeCompact, + formatGenericTag, +} from "@/lib/filter-formatters"; export interface WindowTitleData { title: string; @@ -273,8 +279,8 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { // Generate a descriptive title from the filter const parts: string[] = []; + // 1. Kinds if (filter.kinds && filter.kinds.length > 0) { - // Show actual kind names const kindNames = filter.kinds.map((k: number) => getKindName(k)); if (kindNames.length <= 3) { parts.push(kindNames.join(", ")); @@ -285,13 +291,13 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { } } - // Format hashtags with # prefix + // 2. Hashtags (#t) if (filter["#t"] && filter["#t"].length > 0) { const hashtagText = formatHashtags("#", reqHashtags); if (hashtagText) parts.push(hashtagText); } - // Format tagged users with @ prefix + // 3. Mentions (#p) if (filter["#p"] && filter["#p"].length > 0) { const taggedText = formatProfileNames("@", reqTagged, [ tagged1Profile, @@ -300,7 +306,19 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { if (taggedText) parts.push(taggedText); } - // Format authors with "by " prefix + // 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, @@ -309,6 +327,27 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { 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, t, d) + const genericTags = Object.entries(filter) + .filter(([key]) => key.startsWith("#") && key.length === 2 && !["#e", "#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, diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 5f1e05c..2b46e3e 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -11,9 +11,18 @@ import { import { Virtuoso } from "react-virtuoso"; import { useReqTimeline } from "@/hooks/useReqTimeline"; import { useGrimoire } from "@/core/state"; +import { useProfile } from "@/hooks/useProfile"; import { FeedEvent } from "./nostr/Feed"; import { KindBadge } from "./KindBadge"; import type { NostrFilter } from "@/types/nostr"; +import { + formatEventIds, + formatDTags, + formatTimeRange, + formatGenericTag, + formatPubkeysWithProfiles, + formatHashtags, +} from "@/lib/filter-formatters"; // Memoized FeedEvent to prevent unnecessary re-renders during scroll const MemoizedFeedEvent = memo( @@ -29,6 +38,180 @@ interface ReqViewerProps { nip05PTags?: string[]; } +interface QueryDropdownProps { + filter: NostrFilter; + nip05Authors?: string[]; + nip05PTags?: string[]; +} + +function QueryDropdown({ + filter, + nip05Authors, + nip05PTags, +}: QueryDropdownProps) { + // Load profiles for authors and #p tags + const authorPubkeys = filter.authors || []; + const authorProfiles = authorPubkeys + .slice(0, 10) + .map((pubkey) => useProfile(pubkey)); + + const pTagPubkeys = filter["#p"] || []; + const pTagProfiles = pTagPubkeys + .slice(0, 10) + .map((pubkey) => useProfile(pubkey)); + + // Extract tag filters + const eTags = filter["#e"]; + const tTags = filter["#t"]; + const dTags = filter["#d"]; + + // Find generic tags (exclude #e, #p, #t, #d) + const genericTags = Object.entries(filter) + .filter( + ([key]) => + key.startsWith("#") && + key.length === 2 && + !["#e", "#p", "#t", "#d"].includes(key), + ) + .map(([key, values]) => ({ letter: key[1], values: values as string[] })); + + return ( +
+ {/* Kinds */} + {filter.kinds && filter.kinds.length > 0 && ( +
+ Kinds: + {filter.kinds.map((kind) => ( + + ))} +
+ )} + + {/* Time Range */} + {(filter.since || filter.until) && ( +
+ + Time Range: + + + {formatTimeRange(filter.since, filter.until)} + +
+ )} + + {/* Search */} + {filter.search && ( +
+ Search: + "{filter.search}" +
+ )} + + {/* Authors */} + {authorPubkeys.length > 0 && ( +
+ + Authors: {authorPubkeys.length} + +
+ {formatPubkeysWithProfiles(authorPubkeys, authorProfiles, 3)} +
+ {nip05Authors && nip05Authors.length > 0 && ( +
+ {nip05Authors.map((nip05) => ( +
→ {nip05}
+ ))} +
+ )} +
+ )} + + {/* Tag Filters Section */} + {(eTags || + pTagPubkeys.length > 0 || + tTags || + dTags || + genericTags.length > 0) && ( +
+ + Tag Filters: + + + {/* Event References (#e) */} + {eTags && eTags.length > 0 && ( +
+ #e ({eTags.length}): + {formatEventIds(eTags, 3)} +
+ )} + + {/* Mentions (#p) */} + {pTagPubkeys.length > 0 && ( +
+ #p ({pTagPubkeys.length}): + + {formatPubkeysWithProfiles(pTagPubkeys, pTagProfiles, 3)} + + {nip05PTags && nip05PTags.length > 0 && ( +
+ {nip05PTags.map((nip05) => ( +
→ {nip05}
+ ))} +
+ )} +
+ )} + + {/* Hashtags (#t) */} + {tTags && tTags.length > 0 && ( +
+ #t ({tTags.length}): + {formatHashtags(tTags, 3)} +
+ )} + + {/* D-Tags (#d) */} + {dTags && dTags.length > 0 && ( +
+ #d ({dTags.length}): + {formatDTags(dTags, 3)} +
+ )} + + {/* Generic Tags */} + {genericTags.map((tag) => ( +
+ + #{tag.letter} ({tag.values.length}): + + + {formatGenericTag(tag.letter, tag.values, 3).replace( + `#${tag.letter}: `, + "", + )} + +
+ ))} +
+ )} + + {/* Raw Query */} +
+ Show raw query +
+          {JSON.stringify(filter, null, 2)}
+        
+
+
+ ); +} + export default function ReqViewer({ filter, relays, @@ -155,77 +338,11 @@ export default function ReqViewer({ {/* Expandable Query */} {showQuery && ( -
- {/* Kind Badges */} - {filter.kinds && filter.kinds.length > 0 && ( -
- Kinds: - {filter.kinds.map((kind) => ( - - ))} -
- )} - - {/* Authors with NIP-05 info */} - {filter.authors && filter.authors.length > 0 && ( -
- - Authors: {filter.authors.length} - - {nip05Authors && nip05Authors.length > 0 && ( -
- {nip05Authors.map((nip05) => ( -
→ {nip05}
- ))} -
- )} -
- )} - - {/* #p Tags with NIP-05 info */} - {filter["#p"] && filter["#p"].length > 0 && ( -
- - #p Tags: {filter["#p"].length} - - {nip05PTags && nip05PTags.length > 0 && ( -
- {nip05PTags.map((nip05) => ( -
→ {nip05}
- ))} -
- )} -
- )} - - {/* Limit */} - {filter.limit && ( -
- - Limit: {filter.limit} - -
- )} - - {/* Stream Mode */} - {stream && ( -
- - ● Streaming mode enabled - -
- )} - - {/* Raw Query */} -
- - Query Filter - -
-              {JSON.stringify(filter, null, 2)}
-            
-
-
+ )} {/* Error Display */} diff --git a/src/lib/filter-formatters.test.ts b/src/lib/filter-formatters.test.ts new file mode 100644 index 0000000..003083d --- /dev/null +++ b/src/lib/filter-formatters.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + formatEventIds, + formatDTags, + formatTimeRange, + formatTimeRangeCompact, + formatGenericTag, + formatPubkeysWithProfiles, + formatHashtags, + formatProfileNames, +} from "./filter-formatters"; +import type { ProfileMetadata } from "@/types/profile"; + +// Mock the useLocale module +vi.mock("@/hooks/useLocale", () => ({ + formatTimestamp: vi.fn((timestamp: number, style: string) => { + const now = Math.floor(Date.now() / 1000); + const diff = now - timestamp; + + if (style === "relative") { + const days = Math.floor(diff / 86400); + const hours = Math.floor(diff / 3600); + const minutes = Math.floor(diff / 60); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return "just now"; + } + + if (style === "absolute") { + const date = new Date(timestamp * 1000); + return date.toISOString().slice(0, 16).replace("T", " "); + } + + return new Date(timestamp * 1000).toISOString(); + }), +})); + +describe("formatEventIds", () => { + it("should return empty string for empty array", () => { + expect(formatEventIds([])).toBe(""); + }); + + it("should format single event ID to truncated note1", () => { + const id = "a".repeat(64); // Valid 64-char hex + const result = formatEventIds([id]); + + expect(result).toContain("note1"); + expect(result).toContain("..."); + expect(result.length).toBeLessThan(25); // Truncated + }); + + it("should format two event IDs with comma", () => { + const id1 = "a".repeat(64); + const id2 = "b".repeat(64); + const result = formatEventIds([id1, id2]); + + expect(result).toContain("note1"); + expect(result).toContain(","); + expect(result.split(",")).toHaveLength(2); + }); + + it("should truncate when more than maxDisplay", () => { + const ids = ["a".repeat(64), "b".repeat(64), "c".repeat(64)]; + const result = formatEventIds(ids, 2); + + expect(result).toContain("& 1 more"); + }); + + it("should handle invalid event IDs gracefully", () => { + const invalidId = "not-a-valid-hex-string-that-is-too-long"; + const result = formatEventIds([invalidId]); + + expect(result).toBeTruthy(); + expect(result).toContain("..."); + }); + + it("should handle short invalid IDs without truncation", () => { + const shortInvalidId = "short"; + const result = formatEventIds([shortInvalidId]); + + expect(result).toBe("short"); + }); + + it("should respect custom maxDisplay", () => { + const ids = Array(5).fill("a".repeat(64)); + const result = formatEventIds(ids, 3); + + expect(result).toContain("& 2 more"); + }); +}); + +describe("formatDTags", () => { + it("should return empty string for empty array", () => { + expect(formatDTags([])).toBe(""); + }); + + it("should wrap single tag in quotes", () => { + const result = formatDTags(["note-1"]); + expect(result).toBe('"note-1"'); + }); + + it("should format multiple tags with commas", () => { + const result = formatDTags(["note-1", "note-2"]); + expect(result).toBe('"note-1", "note-2"'); + }); + + it("should truncate when more than maxDisplay", () => { + const tags = ["tag1", "tag2", "tag3", "tag4"]; + const result = formatDTags(tags, 2); + + expect(result).toBe('"tag1", "tag2" & 2 more'); + }); + + it("should handle tags with special characters", () => { + const result = formatDTags(['tag-with-"quotes"', "tag:with:colons"]); + expect(result).toContain('"tag-with-"quotes""'); + expect(result).toContain('"tag:with:colons"'); + }); +}); + +describe("formatTimeRange", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-12-13T12:00:00Z")); + }); + + it("should return empty string when no timestamps", () => { + expect(formatTimeRange()).toBe(""); + }); + + it("should format only since timestamp", () => { + const threeDaysAgo = Math.floor(Date.now() / 1000) - 3 * 86400; + const result = formatTimeRange(threeDaysAgo); + + expect(result).toContain("(3d ago)"); + }); + + it("should format only until timestamp", () => { + const twoDaysAgo = Math.floor(Date.now() / 1000) - 2 * 86400; + const result = formatTimeRange(undefined, twoDaysAgo); + + expect(result).toContain("(2d ago)"); + }); + + it("should format both since and until", () => { + const threeDaysAgo = Math.floor(Date.now() / 1000) - 3 * 86400; + const oneDayAgo = Math.floor(Date.now() / 1000) - 1 * 86400; + const result = formatTimeRange(threeDaysAgo, oneDayAgo); + + expect(result).toContain("→"); + expect(result).toContain("(3d ago)"); + expect(result).toContain("(1d ago)"); + }); + + it("should show 'now' for until timestamp within 60 seconds", () => { + const now = Math.floor(Date.now() / 1000); + const result = formatTimeRange(undefined, now); + + expect(result).toBe("now"); + }); + + it("should handle future timestamps", () => { + const future = Math.floor(Date.now() / 1000) + 3600; + const result = formatTimeRange(undefined, future); + + expect(result).toBeTruthy(); + }); +}); + +describe("formatTimeRangeCompact", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-12-13T12:00:00Z")); + }); + + it("should return empty string when no timestamps", () => { + expect(formatTimeRangeCompact()).toBe(""); + }); + + it("should format 'last Xd' when since provided and until is now", () => { + const threeDaysAgo = Math.floor(Date.now() / 1000) - 3 * 86400; + const now = Math.floor(Date.now() / 1000); + const result = formatTimeRangeCompact(threeDaysAgo, now); + + expect(result).toBe("last 3d"); + }); + + it("should format 'since Xd ago' when only since provided", () => { + const twoDaysAgo = Math.floor(Date.now() / 1000) - 2 * 86400; + const result = formatTimeRangeCompact(twoDaysAgo); + + expect(result).toBe("since 2d ago"); + }); + + it("should format 'until now' when only until provided and is now", () => { + const now = Math.floor(Date.now() / 1000); + const result = formatTimeRangeCompact(undefined, now); + + expect(result).toBe("until now"); + }); + + it("should format with arrow when both timestamps differ", () => { + const threeDaysAgo = Math.floor(Date.now() / 1000) - 3 * 86400; + const twoDaysAgo = Math.floor(Date.now() / 1000) - 2 * 86400; + const result = formatTimeRangeCompact(threeDaysAgo, twoDaysAgo); + + expect(result).toContain("→"); + }); +}); + +describe("formatGenericTag", () => { + it("should return empty string for empty values", () => { + expect(formatGenericTag("a", [])).toBe(""); + }); + + it("should format tag with single value", () => { + const result = formatGenericTag("a", ["value1"]); + expect(result).toBe("#a: value1"); + }); + + it("should format tag with multiple values", () => { + const result = formatGenericTag("r", ["url1", "url2"]); + expect(result).toBe("#r: url1, url2"); + }); + + it("should handle uppercase letters", () => { + const result = formatGenericTag("A", ["value1", "value2"]); + expect(result).toBe("#A: value1, value2"); + }); + + it("should handle lowercase letters", () => { + const result = formatGenericTag("z", ["value1"]); + expect(result).toBe("#z: value1"); + }); + + it("should truncate long values", () => { + const longValue = "a".repeat(50); + const result = formatGenericTag("a", [longValue]); + + expect(result).toContain("..."); + expect(result.length).toBeLessThan(50); + }); + + it("should truncate when more than maxDisplay", () => { + const values = ["val1", "val2", "val3", "val4"]; + const result = formatGenericTag("g", values, 2); + + expect(result).toBe("#g: val1, val2 & 2 more"); + }); +}); + +describe("formatPubkeysWithProfiles", () => { + const mockProfile: ProfileMetadata = { + name: "Alice", + display_name: "Alice in Wonderland", + about: "Test user", + picture: "", + banner: "", + nip05: "alice@example.com", + lud06: "", + lud16: "", + website: "", + }; + + it("should return empty string for empty array", () => { + expect(formatPubkeysWithProfiles([], [])).toBe(""); + }); + + it("should format pubkey with profile name", () => { + const pubkey = "a".repeat(64); + const result = formatPubkeysWithProfiles([pubkey], [mockProfile]); + + expect(result).toContain("npub1"); + expect(result).toContain("(Alice)"); + }); + + it("should format pubkey without profile", () => { + const pubkey = "a".repeat(64); + const result = formatPubkeysWithProfiles([pubkey], [null]); + + expect(result).toContain("npub1"); + expect(result).not.toContain("("); + }); + + it("should format multiple pubkeys with mixed profiles", () => { + const pubkey1 = "a".repeat(64); + const pubkey2 = "b".repeat(64); + const result = formatPubkeysWithProfiles( + [pubkey1, pubkey2], + [mockProfile, null], + ); + + expect(result).toContain("(Alice)"); + expect(result).toContain(","); + }); + + it("should truncate when more than maxDisplay", () => { + const pubkeys = Array(4).fill("a".repeat(64)); + const profiles = Array(4).fill(null); + const result = formatPubkeysWithProfiles(pubkeys, profiles, 2); + + expect(result).toContain("& 2 more"); + }); +}); + +describe("formatHashtags", () => { + it("should return empty string for empty array", () => { + expect(formatHashtags([])).toBe(""); + }); + + it("should add # prefix to single tag", () => { + const result = formatHashtags(["bitcoin"]); + expect(result).toBe("#bitcoin"); + }); + + it("should format multiple tags with commas", () => { + const result = formatHashtags(["bitcoin", "nostr"]); + expect(result).toBe("#bitcoin, #nostr"); + }); + + it("should truncate when more than maxDisplay", () => { + const tags = ["bitcoin", "nostr", "lightning", "web3"]; + const result = formatHashtags(tags, 2); + + expect(result).toBe("#bitcoin, #nostr & 2 more"); + }); +}); + +describe("formatProfileNames", () => { + it("should return empty string for empty array", () => { + expect(formatProfileNames([])).toBe(""); + }); + + it("should use name from profile", () => { + const profile: ProfileMetadata = { + name: "Alice", + display_name: "", + about: "", + picture: "", + banner: "", + nip05: "", + lud06: "", + lud16: "", + website: "", + }; + const result = formatProfileNames([profile]); + expect(result).toBe("Alice"); + }); + + it("should use display_name if name is empty", () => { + const profile: ProfileMetadata = { + name: "", + display_name: "Alice Display", + about: "", + picture: "", + banner: "", + nip05: "", + lud06: "", + lud16: "", + website: "", + }; + const result = formatProfileNames([profile]); + expect(result).toBe("Alice Display"); + }); + + it("should use 'Unknown' if both name and display_name are empty", () => { + const profile: ProfileMetadata = { + name: "", + display_name: "", + about: "", + picture: "", + banner: "", + nip05: "", + lud06: "", + lud16: "", + website: "", + }; + const result = formatProfileNames([profile]); + expect(result).toBe("Unknown"); + }); + + it("should format multiple profiles", () => { + const profile1: ProfileMetadata = { + name: "Alice", + display_name: "", + about: "", + picture: "", + banner: "", + nip05: "", + lud06: "", + lud16: "", + website: "", + }; + const profile2: ProfileMetadata = { + name: "Bob", + display_name: "", + about: "", + picture: "", + banner: "", + nip05: "", + lud06: "", + lud16: "", + website: "", + }; + const result = formatProfileNames([profile1, profile2]); + expect(result).toBe("Alice, Bob"); + }); + + it("should truncate when more than maxDisplay", () => { + const profiles = Array(5) + .fill(null) + .map( + (_, i): ProfileMetadata => ({ + name: `User${i}`, + display_name: "", + about: "", + picture: "", + banner: "", + nip05: "", + lud06: "", + lud16: "", + website: "", + }), + ); + const result = formatProfileNames(profiles, 2); + + expect(result).toBe("User0, User1 & 3 more"); + }); +}); diff --git a/src/lib/filter-formatters.ts b/src/lib/filter-formatters.ts new file mode 100644 index 0000000..62d325b --- /dev/null +++ b/src/lib/filter-formatters.ts @@ -0,0 +1,233 @@ +import { nip19 } from "nostr-tools"; +import { formatTimestamp } from "@/hooks/useLocale"; +import type { ProfileMetadata } from "@/types/profile"; + +/** + * Truncate a bech32-encoded string (note1..., npub1..., etc.) + * @param bech32 - Full bech32 string + * @returns Truncated string like "note1abc...xyz" + */ +function truncateBech32(bech32: string): string { + if (bech32.length <= 20) return bech32; + + // Find prefix length (note1, npub1, etc.) + const prefixMatch = bech32.match(/^[a-z]+1/); + const prefixLen = prefixMatch ? prefixMatch[0].length : 5; + + // Show prefix + first 6 chars + ... + last 4 chars + const start = bech32.slice(0, prefixLen + 6); + const end = bech32.slice(-4); + return `${start}...${end}`; +} + +/** + * Format a list with truncation (e.g., "item1, item2 & 3 more") + * @param items - Array of strings to format + * @param maxDisplay - Maximum items to display before truncating + * @returns Formatted string with truncation + */ +function formatList(items: string[], maxDisplay: number): string { + if (items.length === 0) return ""; + if (items.length <= maxDisplay) return items.join(", "); + + const displayed = items.slice(0, maxDisplay); + const remaining = items.length - maxDisplay; + return `${displayed.join(", ")} & ${remaining} more`; +} + +/** + * Format event IDs to truncated note1... strings + * @param ids - Hex event IDs (64-char hex strings) + * @param maxDisplay - Maximum IDs to show before truncating (default: 2) + * @returns Formatted string like "note1abc...xyz, note1def...uvw & 3 more" + */ +export function formatEventIds(ids: string[], maxDisplay = 2): string { + if (!ids || ids.length === 0) return ""; + + const encoded = ids + .map((id) => { + try { + const note = nip19.noteEncode(id); + return truncateBech32(note); + } catch { + // Fallback for invalid IDs: truncate hex + return id.length > 16 ? `${id.slice(0, 8)}...${id.slice(-6)}` : id; + } + }); + + return formatList(encoded, maxDisplay); +} + +/** + * Format d-tags with quotes and truncation + * @param tags - Array of d-tag strings + * @param maxDisplay - Maximum tags to show before truncating (default: 2) + * @returns Formatted string like '"note-1", "note-2" & 1 more' + */ +export function formatDTags(tags: string[], maxDisplay = 2): string { + if (!tags || tags.length === 0) return ""; + + const quoted = tags.map((tag) => `"${tag}"`); + return formatList(quoted, maxDisplay); +} + +/** + * Format time range with relative and absolute display + * @param since - Unix timestamp (seconds) for start time + * @param until - Unix timestamp (seconds) for end time + * @returns Formatted string like "2025-12-10 14:30 (3d ago) → now" + */ +export function formatTimeRange(since?: number, until?: number): string { + if (!since && !until) return ""; + + const parts: string[] = []; + + if (since) { + const absolute = formatTimestamp(since, "absolute"); + const relative = formatTimestamp(since, "relative"); + parts.push(`${absolute} (${relative})`); + } + + if (until) { + // Check if until is approximately now (within 60 seconds) + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - until) < 60) { + parts.push("now"); + } else { + const absolute = formatTimestamp(until, "absolute"); + const relative = formatTimestamp(until, "relative"); + parts.push(`${absolute} (${relative})`); + } + } + + return parts.join(" → "); +} + +/** + * Format time range in compact form for window titles + * @param since - Unix timestamp (seconds) for start time + * @param until - Unix timestamp (seconds) for end time + * @returns Compact string like "last 3d" or "since 2d ago" + */ +export function formatTimeRangeCompact(since?: number, until?: number): string { + if (!since && !until) return ""; + + const now = Math.floor(Date.now() / 1000); + + // If both since and until, and until is approximately now + if (since && until && Math.abs(now - until) < 60) { + const relative = formatTimestamp(since, "relative"); + return `last ${relative.replace(" ago", "")}`; + } + + // If only since + if (since && !until) { + const relative = formatTimestamp(since, "relative"); + return `since ${relative}`; + } + + // If only until + if (until && !since) { + if (Math.abs(now - until) < 60) { + return "until now"; + } + const relative = formatTimestamp(until, "relative"); + return `until ${relative}`; + } + + // Both with specific until + if (since && until) { + const sinceRel = formatTimestamp(since, "relative"); + const untilRel = formatTimestamp(until, "relative"); + return `${sinceRel} → ${untilRel}`; + } + + return ""; +} + +/** + * Format generic tag with letter prefix + * @param letter - Single letter tag identifier (e.g., 'a', 'r', 'g') + * @param values - Array of tag values + * @param maxDisplay - Maximum values to show before truncating (default: 2) + * @returns Formatted string like "#a: val1, val2 & 1 more" + */ +export function formatGenericTag( + letter: string, + values: string[], + maxDisplay = 2, +): string { + if (!values || values.length === 0) return ""; + + // Truncate long values (e.g., URLs, addresses) + const truncated = values.map((val) => { + if (val.length > 40) { + return `${val.slice(0, 20)}...${val.slice(-10)}`; + } + return val; + }); + + const formatted = formatList(truncated, maxDisplay); + return `#${letter}: ${formatted}`; +} + +/** + * Format pubkeys with profile names and npub encoding + * @param pubkeys - Array of hex pubkeys + * @param profiles - Array of loaded ProfileMetadata objects (may be sparse) + * @param maxDisplay - Maximum pubkeys to show before truncating (default: 2) + * @returns Formatted string like "npub1... (Alice), npub1... (Bob) & 2 more" + */ +export function formatPubkeysWithProfiles( + pubkeys: string[], + profiles: (ProfileMetadata | null | undefined)[], + maxDisplay = 2, +): string { + if (!pubkeys || pubkeys.length === 0) return ""; + + const formatted = pubkeys.map((pubkey, index) => { + const profile = profiles[index]; + const npub = nip19.npubEncode(pubkey); + const truncatedNpub = truncateBech32(npub); + + if (profile?.name) { + return `${truncatedNpub} (${profile.name})`; + } + + return truncatedNpub; + }); + + return formatList(formatted, maxDisplay); +} + +/** + * Format hashtags with # prefix and truncation + * @param tags - Array of hashtag strings (without # prefix) + * @param maxDisplay - Maximum hashtags to show before truncating (default: 2) + * @returns Formatted string like "#bitcoin, #nostr & 3 more" + */ +export function formatHashtags(tags: string[], maxDisplay = 2): string { + if (!tags || tags.length === 0) return ""; + + const withHash = tags.map((tag) => `#${tag}`); + return formatList(withHash, maxDisplay); +} + +/** + * Format profile names for display + * @param profiles - Array of ProfileMetadata objects + * @param maxDisplay - Maximum profiles to show before truncating (default: 2) + * @returns Formatted string like "Alice, Bob & 2 more" + */ +export function formatProfileNames( + profiles: ProfileMetadata[], + maxDisplay = 2, +): string { + if (!profiles || profiles.length === 0) return ""; + + const names = profiles + .map((p) => p.name || p.display_name || "Unknown") + .filter(Boolean); + + return formatList(names, maxDisplay); +}