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 */}