mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 23:16:50 +02:00
ui: friendlier req viewer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
431
src/lib/filter-formatters.test.ts
Normal file
431
src/lib/filter-formatters.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
233
src/lib/filter-formatters.ts
Normal file
233
src/lib/filter-formatters.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user