ui: friendlier req viewer

This commit is contained in:
Alejandro Gómez
2025-12-14 00:07:06 +01:00
parent 26fc2bf7af
commit c21df2b420
5 changed files with 895 additions and 83 deletions

View File

@@ -123,14 +123,6 @@ export default function CommandLauncher({
className="command-input"
/>
{recognizedCommand && parsed.args.length > 0 && (
<div className="command-hint">
<span className="command-hint-label">Parsed:</span>
<span className="command-hint-command">{commandName}</span>
<span className="command-hint-args">{parsed.args.join(" ")}</span>
</div>
)}
<Command.List className="command-list">
<Command.Empty className="command-empty">
{commandName

View File

@@ -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,

View File

@@ -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 (
<div className="border-b border-border px-4 py-2 bg-muted space-y-2">
{/* Kinds */}
{filter.kinds && filter.kinds.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-semibold text-foreground">Kinds:</span>
{filter.kinds.map((kind) => (
<KindBadge
key={kind}
kind={kind}
iconClassname="size-3"
className="text-xs"
clickable
/>
))}
</div>
)}
{/* Time Range */}
{(filter.since || filter.until) && (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold text-foreground">
Time Range:
</span>
<span className="text-xs ml-2">
{formatTimeRange(filter.since, filter.until)}
</span>
</div>
)}
{/* Search */}
{filter.search && (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold text-foreground">Search:</span>
<span className="text-xs ml-2">"{filter.search}"</span>
</div>
)}
{/* Authors */}
{authorPubkeys.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold text-foreground">
Authors: {authorPubkeys.length}
</span>
<div className="text-xs ml-2">
{formatPubkeysWithProfiles(authorPubkeys, authorProfiles, 3)}
</div>
{nip05Authors && nip05Authors.length > 0 && (
<div className="text-xs ml-2 mt-1 space-y-0.5">
{nip05Authors.map((nip05) => (
<div key={nip05}> {nip05}</div>
))}
</div>
)}
</div>
)}
{/* Tag Filters Section */}
{(eTags ||
pTagPubkeys.length > 0 ||
tTags ||
dTags ||
genericTags.length > 0) && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-foreground">
Tag Filters:
</span>
{/* Event References (#e) */}
{eTags && eTags.length > 0 && (
<div className="flex flex-col">
<span className="text-xs">#e ({eTags.length}):</span>
<span className="text-xs">{formatEventIds(eTags, 3)}</span>
</div>
)}
{/* Mentions (#p) */}
{pTagPubkeys.length > 0 && (
<div className="flex flex-col">
<span className="text-xs">#p ({pTagPubkeys.length}):</span>
<span className="text-xs">
{formatPubkeysWithProfiles(pTagPubkeys, pTagProfiles, 3)}
</span>
{nip05PTags && nip05PTags.length > 0 && (
<div className="text-xs space-y-0.5">
{nip05PTags.map((nip05) => (
<div key={nip05}> {nip05}</div>
))}
</div>
)}
</div>
)}
{/* Hashtags (#t) */}
{tTags && tTags.length > 0 && (
<div className="flex flex-col">
<span className="text-xs">#t ({tTags.length}):</span>
<span className="text-xs">{formatHashtags(tTags, 3)}</span>
</div>
)}
{/* D-Tags (#d) */}
{dTags && dTags.length > 0 && (
<div className="flex flex-col">
<span className="text-xs">#d ({dTags.length}):</span>
<span className="text-xs">{formatDTags(dTags, 3)}</span>
</div>
)}
{/* Generic Tags */}
{genericTags.map((tag) => (
<div key={tag.letter} className="flex flex-col">
<span className="text-xs">
#{tag.letter} ({tag.values.length}):
</span>
<span className="text-xs">
{formatGenericTag(tag.letter, tag.values, 3).replace(
`#${tag.letter}: `,
"",
)}
</span>
</div>
))}
</div>
)}
{/* Raw Query */}
<details className="text-xs">
<summary className="cursor-pointer">Show raw query</summary>
<pre className="mt-2 text-xs font-mono bg-background p-2 border border-border overflow-x-auto">
{JSON.stringify(filter, null, 2)}
</pre>
</details>
</div>
);
}
export default function ReqViewer({
filter,
relays,
@@ -155,77 +338,11 @@ export default function ReqViewer({
{/* Expandable Query */}
{showQuery && (
<div className="border-b border-border px-4 py-2 bg-muted space-y-2">
{/* Kind Badges */}
{filter.kinds && filter.kinds.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Kinds:</span>
{filter.kinds.map((kind) => (
<KindBadge key={kind} kind={kind} variant="full" />
))}
</div>
)}
{/* Authors with NIP-05 info */}
{filter.authors && filter.authors.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
Authors: {filter.authors.length}
</span>
{nip05Authors && nip05Authors.length > 0 && (
<div className="text-xs text-muted-foreground ml-2">
{nip05Authors.map((nip05) => (
<div key={nip05}> {nip05}</div>
))}
</div>
)}
</div>
)}
{/* #p Tags with NIP-05 info */}
{filter["#p"] && filter["#p"].length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
#p Tags: {filter["#p"].length}
</span>
{nip05PTags && nip05PTags.length > 0 && (
<div className="text-xs text-muted-foreground ml-2">
{nip05PTags.map((nip05) => (
<div key={nip05}> {nip05}</div>
))}
</div>
)}
</div>
)}
{/* Limit */}
{filter.limit && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Limit: {filter.limit}
</span>
</div>
)}
{/* Stream Mode */}
{stream && (
<div className="flex items-center gap-2">
<span className="text-xs text-green-500">
Streaming mode enabled
</span>
</div>
)}
{/* Raw Query */}
<details className="text-xs">
<summary className="cursor-crosshair text-muted-foreground hover:text-foreground">
Query Filter
</summary>
<pre className="mt-2 text-xs font-mono text-muted-foreground bg-background p-2 overflow-x-auto">
{JSON.stringify(filter, null, 2)}
</pre>
</details>
</div>
<QueryDropdown
filter={filter}
nip05Authors={nip05Authors}
nip05PTags={nip05PTags}
/>
)}
{/* Error Display */}

View File

@@ -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");
});
});

View File

@@ -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);
}