mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
feat: -P filter tag
This commit is contained in:
@@ -3,6 +3,7 @@ import { WindowInstance } from "@/types/app";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { getKindName, getKindIcon } from "@/constants/kinds";
|
||||
import { getNipTitle } from "@/constants/nips";
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from "@/lib/filter-formatters";
|
||||
import { getEventDisplayTitle } from "@/lib/event-title";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { getTagValues } from "@/lib/nostr-utils";
|
||||
|
||||
export interface WindowTitleData {
|
||||
title: string | ReactElement;
|
||||
@@ -29,45 +31,64 @@ export interface WindowTitleData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format profile names with prefix
|
||||
* Format profile names with prefix, handling $me and $contacts aliases
|
||||
* @param prefix - Prefix to use (e.g., 'by ', '@ ')
|
||||
* @param pubkeys - Array of pubkeys to format
|
||||
* @param pubkeys - Array of pubkeys to format (may include $me or $contacts)
|
||||
* @param profiles - Array of corresponding profile metadata
|
||||
* @param accountProfile - Profile of active account for $me resolution
|
||||
* @param contactsCount - Number of contacts for $contacts display
|
||||
* @returns Formatted string like "by Alice, Bob & 3 others" or null if no pubkeys
|
||||
*/
|
||||
function formatProfileNames(
|
||||
prefix: string,
|
||||
pubkeys: string[],
|
||||
profiles: (ProfileContent | undefined)[],
|
||||
accountProfile?: ProfileContent,
|
||||
contactsCount?: number,
|
||||
): string | null {
|
||||
if (!pubkeys.length) return null;
|
||||
|
||||
const names: string[] = [];
|
||||
const [pubkey1, pubkey2] = pubkeys;
|
||||
const [profile1, profile2] = profiles;
|
||||
let processedCount = 0;
|
||||
|
||||
// Add first profile
|
||||
if (profile1) {
|
||||
const name = profile1.display_name || profile1.name;
|
||||
names.push(name || `${pubkey1.slice(0, 8)}...`);
|
||||
} else if (pubkey1) {
|
||||
names.push(`${pubkey1.slice(0, 8)}...`);
|
||||
}
|
||||
// Process first two pubkeys (may be aliases or real pubkeys)
|
||||
for (let i = 0; i < Math.min(2, pubkeys.length); i++) {
|
||||
const pubkey = pubkeys[i];
|
||||
const profile = profiles[i];
|
||||
|
||||
// Add second profile if exists
|
||||
if (pubkeys.length > 1) {
|
||||
if (profile2) {
|
||||
const name = profile2.display_name || profile2.name;
|
||||
names.push(name || `${pubkey2.slice(0, 8)}...`);
|
||||
} else if (pubkey2) {
|
||||
names.push(`${pubkey2.slice(0, 8)}...`);
|
||||
if (pubkey === "$me") {
|
||||
// Show account's name or "You"
|
||||
if (accountProfile) {
|
||||
const name = accountProfile.display_name || accountProfile.name || "You";
|
||||
names.push(name);
|
||||
} else {
|
||||
names.push("You");
|
||||
}
|
||||
processedCount++;
|
||||
} else if (pubkey === "$contacts") {
|
||||
// Show "Your Contacts" with count
|
||||
if (contactsCount !== undefined && contactsCount > 0) {
|
||||
names.push(`Your Contacts (${contactsCount})`);
|
||||
} else {
|
||||
names.push("Your Contacts");
|
||||
}
|
||||
processedCount++;
|
||||
} else {
|
||||
// Regular pubkey
|
||||
if (profile) {
|
||||
const name = profile.display_name || profile.name;
|
||||
names.push(name || `${pubkey.slice(0, 8)}...`);
|
||||
} else {
|
||||
names.push(`${pubkey.slice(0, 8)}...`);
|
||||
}
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add "& X other(s)" if more than 2
|
||||
// Add "& X more" if more than 2
|
||||
if (pubkeys.length > 2) {
|
||||
const othersCount = pubkeys.length - 2;
|
||||
names.push(`& ${othersCount} other${othersCount > 1 ? "s" : ""}`);
|
||||
names.push(`& ${othersCount} more`);
|
||||
}
|
||||
|
||||
return names.length > 0 ? `${prefix}${names.join(", ")}` : null;
|
||||
@@ -166,7 +187,25 @@ function generateRawCommand(appId: string, props: any): string {
|
||||
parts.push(`-t ${props.filter["#t"].slice(0, 2).join(",")}`);
|
||||
}
|
||||
if (props.filter.authors?.length) {
|
||||
parts.push(`-a ${props.filter.authors.slice(0, 2).join(",")}`);
|
||||
// Keep original aliases in tooltip for clarity
|
||||
const authorDisplay = props.filter.authors
|
||||
.slice(0, 2)
|
||||
.join(",");
|
||||
parts.push(`-a ${authorDisplay}`);
|
||||
}
|
||||
if (props.filter["#p"]?.length) {
|
||||
// Keep original aliases in tooltip for clarity
|
||||
const pTagDisplay = props.filter["#p"]
|
||||
.slice(0, 2)
|
||||
.join(",");
|
||||
parts.push(`-p ${pTagDisplay}`);
|
||||
}
|
||||
if (props.filter["#P"]?.length) {
|
||||
// Keep original aliases in tooltip for clarity
|
||||
const pTagUpperDisplay = props.filter["#P"]
|
||||
.slice(0, 2)
|
||||
.join(",");
|
||||
parts.push(`-P ${pTagUpperDisplay}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
@@ -194,6 +233,24 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
// Get relay state for conn viewer
|
||||
const { relays } = useRelayState();
|
||||
|
||||
// Get account state for alias resolution
|
||||
const { state } = useGrimoire();
|
||||
const activeAccount = state.activeAccount;
|
||||
const accountPubkey = activeAccount?.pubkey;
|
||||
|
||||
// Fetch account profile for $me display
|
||||
const accountProfile = useProfile(accountPubkey || "");
|
||||
|
||||
// Fetch contact list for $contacts display
|
||||
const contactListEvent = useNostrEvent(
|
||||
accountPubkey ? { kind: 3, pubkey: accountPubkey, identifier: "" } : undefined,
|
||||
);
|
||||
|
||||
// Extract contacts count from kind 3 event
|
||||
const contactsCount = contactListEvent
|
||||
? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64).length
|
||||
: 0;
|
||||
|
||||
// Profile titles
|
||||
const profilePubkey = appId === "profile" ? props.pubkey : null;
|
||||
const profile = useProfile(profilePubkey || "");
|
||||
@@ -258,6 +315,12 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
const tagged1Profile = useProfile(tagged1Pubkey);
|
||||
const tagged2Profile = useProfile(tagged2Pubkey);
|
||||
|
||||
const reqTaggedUppercase =
|
||||
appId === "req" && props.filter?.["#P"] ? props.filter["#P"] : [];
|
||||
const [taggedUpper1Pubkey, taggedUpper2Pubkey] = reqTaggedUppercase;
|
||||
const taggedUpper1Profile = useProfile(taggedUpper1Pubkey);
|
||||
const taggedUpper2Profile = useProfile(taggedUpper2Pubkey);
|
||||
|
||||
const reqHashtags =
|
||||
appId === "req" && props.filter?.["#t"] ? props.filter["#t"] : [];
|
||||
|
||||
@@ -289,13 +352,28 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
|
||||
// 3. Mentions (#p)
|
||||
if (filter["#p"] && filter["#p"].length > 0) {
|
||||
const taggedText = formatProfileNames("@", reqTagged, [
|
||||
tagged1Profile,
|
||||
tagged2Profile,
|
||||
]);
|
||||
const taggedText = formatProfileNames(
|
||||
"@",
|
||||
reqTagged,
|
||||
[tagged1Profile, tagged2Profile],
|
||||
accountProfile,
|
||||
contactsCount,
|
||||
);
|
||||
if (taggedText) parts.push(taggedText);
|
||||
}
|
||||
|
||||
// 3b. Zap Senders (#P)
|
||||
if (filter["#P"] && filter["#P"].length > 0) {
|
||||
const zapSendersText = formatProfileNames(
|
||||
"⚡ from ",
|
||||
reqTaggedUppercase,
|
||||
[taggedUpper1Profile, taggedUpper2Profile],
|
||||
accountProfile,
|
||||
contactsCount,
|
||||
);
|
||||
if (zapSendersText) parts.push(zapSendersText);
|
||||
}
|
||||
|
||||
// 4. Event References (#e) - NEW
|
||||
if (filter["#e"] && filter["#e"].length > 0) {
|
||||
const eventIdsText = formatEventIds(filter["#e"], 2);
|
||||
@@ -310,10 +388,13 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
|
||||
// 6. Authors
|
||||
if (filter.authors && filter.authors.length > 0) {
|
||||
const authorsText = formatProfileNames("by ", reqAuthors, [
|
||||
author1Profile,
|
||||
author2Profile,
|
||||
]);
|
||||
const authorsText = formatProfileNames(
|
||||
"by ",
|
||||
reqAuthors,
|
||||
[author1Profile, author2Profile],
|
||||
accountProfile,
|
||||
contactsCount,
|
||||
);
|
||||
if (authorsText) parts.push(authorsText);
|
||||
}
|
||||
|
||||
@@ -323,13 +404,13 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
if (timeRangeText) parts.push(`📅 ${timeRangeText}`);
|
||||
}
|
||||
|
||||
// 8. Generic Tags - NEW (a-z, A-Z filters excluding e, p, t, d)
|
||||
// 8. Generic Tags - NEW (a-z, A-Z filters excluding e, p, P, t, d)
|
||||
const genericTags = Object.entries(filter)
|
||||
.filter(
|
||||
([key]) =>
|
||||
key.startsWith("#") &&
|
||||
key.length === 2 &&
|
||||
!["#e", "#p", "#t", "#d"].includes(key),
|
||||
!["#e", "#p", "#P", "#t", "#d"].includes(key),
|
||||
)
|
||||
.map(([key, values]) => ({ letter: key[1], values: values as string[] }));
|
||||
|
||||
@@ -349,11 +430,16 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
props,
|
||||
reqAuthors,
|
||||
reqTagged,
|
||||
reqTaggedUppercase,
|
||||
reqHashtags,
|
||||
author1Profile,
|
||||
author2Profile,
|
||||
tagged1Profile,
|
||||
tagged2Profile,
|
||||
taggedUpper1Profile,
|
||||
taggedUpper2Profile,
|
||||
accountProfile,
|
||||
contactsCount,
|
||||
]);
|
||||
|
||||
// Encode/Decode titles
|
||||
|
||||
@@ -64,6 +64,8 @@ import { useCopy } from "@/hooks/useCopy";
|
||||
import { CodeCopyButton } from "@/components/CodeCopyButton";
|
||||
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
|
||||
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
||||
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
|
||||
// Memoized FeedEvent to prevent unnecessary re-renders during scroll
|
||||
const MemoizedFeedEvent = memo(
|
||||
@@ -77,6 +79,7 @@ interface ReqViewerProps {
|
||||
closeOnEose?: boolean;
|
||||
nip05Authors?: string[];
|
||||
nip05PTags?: string[];
|
||||
needsAccount?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@@ -114,14 +117,15 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
|
||||
)
|
||||
.map(([key, values]) => ({ letter: key[1], values: values as string[] }));
|
||||
|
||||
// Calculate summary counts
|
||||
// Calculate summary counts (excluding #p which is shown separately as mentions)
|
||||
const tagCount =
|
||||
(eTags?.length || 0) +
|
||||
(pTagPubkeys.length || 0) +
|
||||
(tTags?.length || 0) +
|
||||
(dTags?.length || 0) +
|
||||
genericTags.reduce((sum, tag) => sum + tag.values.length, 0);
|
||||
|
||||
const mentionCount = pTagPubkeys.length;
|
||||
|
||||
// Determine if we should use accordion for complex queries
|
||||
const isComplexQuery =
|
||||
(filter.kinds?.length || 0) +
|
||||
@@ -147,6 +151,12 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
|
||||
{authorPubkeys.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{mentionCount > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<User className="size-3.5" />
|
||||
{mentionCount} mention{mentionCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{(filter.since || filter.until) && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="size-3.5" />
|
||||
@@ -171,7 +181,7 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
|
||||
/* Accordion for complex queries */
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={["kinds", "authors", "time", "search", "tags"]}
|
||||
defaultValue={["kinds", "authors", "mentions", "time", "search", "tags"]}
|
||||
className="space-y-2"
|
||||
>
|
||||
{/* Kinds Section */}
|
||||
@@ -281,6 +291,46 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* Mentions Section */}
|
||||
{pTagPubkeys.length > 0 && (
|
||||
<AccordionItem value="mentions" className="border-0">
|
||||
<AccordionTrigger className="py-2 hover:no-underline">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold">
|
||||
<User className="size-3.5 text-muted-foreground" />
|
||||
Mentions ({pTagPubkeys.length})
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-2 ml-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pTagPubkeys
|
||||
.slice(0, showAllPTags ? undefined : 3)
|
||||
.map((pubkey) => {
|
||||
return (
|
||||
<UserName
|
||||
key={pubkey}
|
||||
pubkey={pubkey}
|
||||
isMention
|
||||
className="text-xs"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pTagPubkeys.length > 3 && (
|
||||
<button
|
||||
onClick={() => setShowAllPTags(!showAllPTags)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{showAllPTags
|
||||
? "Show less"
|
||||
: `Show all ${pTagPubkeys.length}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* Tags Section */}
|
||||
{tagCount > 0 && (
|
||||
<AccordionItem value="tags" className="border-0">
|
||||
@@ -325,39 +375,6 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mentions (#p) */}
|
||||
{pTagPubkeys.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium">
|
||||
Mentions ({pTagPubkeys.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pTagPubkeys
|
||||
.slice(0, showAllPTags ? undefined : 3)
|
||||
.map((pubkey) => {
|
||||
return (
|
||||
<UserName
|
||||
key={pubkey}
|
||||
pubkey={pubkey}
|
||||
isMention
|
||||
className="text-xs"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pTagPubkeys.length > 3 && (
|
||||
<button
|
||||
onClick={() => setShowAllPTags(!showAllPTags)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{showAllPTags
|
||||
? "Show less"
|
||||
: `Show all ${pTagPubkeys.length}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hashtags (#t) */}
|
||||
{tTags && tTags.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
@@ -507,6 +524,42 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mentions */}
|
||||
{pTagPubkeys.length > 0 && (
|
||||
<div className="">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold mb-1.5">
|
||||
<User className="size-3.5 text-muted-foreground" />
|
||||
Mentions ({pTagPubkeys.length})
|
||||
</div>
|
||||
<div className="ml-5 space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pTagPubkeys
|
||||
.slice(0, showAllPTags ? undefined : 3)
|
||||
.map((pubkey) => {
|
||||
return (
|
||||
<UserName
|
||||
key={pubkey}
|
||||
pubkey={pubkey}
|
||||
isMention
|
||||
className="text-xs"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pTagPubkeys.length > 3 && (
|
||||
<button
|
||||
onClick={() => setShowAllPTags(!showAllPTags)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{showAllPTags
|
||||
? "Show less"
|
||||
: `Show all ${pTagPubkeys.length}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags (simplified for simple queries) */}
|
||||
{tagCount > 0 && (
|
||||
<div className="">
|
||||
@@ -518,27 +571,6 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
|
||||
{eTags && eTags.length > 0 && (
|
||||
<div>Event refs: {formatEventIds(eTags, 3)}</div>
|
||||
)}
|
||||
{pTagPubkeys.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span>Mentions:</span>
|
||||
{pTagPubkeys.slice(0, 3).map((pubkey, idx) => (
|
||||
<span
|
||||
key={pubkey}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
<UserName
|
||||
pubkey={pubkey}
|
||||
isMention
|
||||
className="text-xs"
|
||||
/>
|
||||
{idx < Math.min(2, pTagPubkeys.length - 1) && ","}
|
||||
</span>
|
||||
))}
|
||||
{pTagPubkeys.length > 3 && (
|
||||
<span>...+{pTagPubkeys.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tTags && tTags.length > 0 && (
|
||||
<div>Hashtags: {formatHashtags(tTags, 3)}</div>
|
||||
)}
|
||||
@@ -592,11 +624,33 @@ export default function ReqViewer({
|
||||
closeOnEose = false,
|
||||
nip05Authors,
|
||||
nip05PTags,
|
||||
needsAccount = false,
|
||||
title = "nostr-events",
|
||||
}: ReqViewerProps) {
|
||||
const { state } = useGrimoire();
|
||||
const { relays: relayStates } = useRelayState();
|
||||
|
||||
// Get active account for alias resolution
|
||||
const activeAccount = state.activeAccount;
|
||||
const accountPubkey = activeAccount?.pubkey;
|
||||
|
||||
// Fetch contact list (kind 3) if needed for $contacts resolution
|
||||
const contactListEvent = useNostrEvent(
|
||||
needsAccount && accountPubkey
|
||||
? { kind: 3, pubkey: accountPubkey, identifier: "" }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Extract contacts from kind 3 event
|
||||
const contacts = contactListEvent
|
||||
? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64)
|
||||
: [];
|
||||
|
||||
// Resolve $me and $contacts aliases
|
||||
const resolvedFilter = needsAccount
|
||||
? resolveFilterAliases(filter, accountPubkey, contacts)
|
||||
: filter;
|
||||
|
||||
// NIP-05 resolution already happened in argParser before window creation
|
||||
// The filter prop already contains resolved pubkeys
|
||||
// We just display the NIP-05 identifiers for user reference
|
||||
@@ -622,9 +676,9 @@ export default function ReqViewer({
|
||||
|
||||
const { events, loading, error, eoseReceived } = useReqTimeline(
|
||||
`req-${JSON.stringify(filter)}-${closeOnEose}`,
|
||||
filter,
|
||||
resolvedFilter,
|
||||
defaultRelays,
|
||||
{ limit: filter.limit || 50, stream },
|
||||
{ limit: resolvedFilter.limit || 50, stream },
|
||||
);
|
||||
|
||||
const [showQuery, setShowQuery] = useState(false);
|
||||
@@ -883,7 +937,7 @@ export default function ReqViewer({
|
||||
{/* Expandable Query */}
|
||||
{showQuery && (
|
||||
<QueryDropdown
|
||||
filter={filter}
|
||||
filter={resolvedFilter}
|
||||
nip05Authors={nip05Authors}
|
||||
nip05PTags={nip05PTags}
|
||||
/>
|
||||
@@ -898,38 +952,55 @@ export default function ReqViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Required Error */}
|
||||
{needsAccount && !accountPubkey && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<User className="size-12 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-semibold mb-2">Account Required</h3>
|
||||
<p className="text-sm max-w-md">
|
||||
This query uses <code className="bg-muted px-1.5 py-0.5">$me</code>{" "}
|
||||
or <code className="bg-muted px-1.5 py-0.5">$contacts</code>{" "}
|
||||
aliases and requires an active account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Loading: Before EOSE received */}
|
||||
{loading && events.length === 0 && !eoseReceived && (
|
||||
<div className="p-4">
|
||||
<TimelineSkeleton count={5} />
|
||||
</div>
|
||||
)}
|
||||
{(!needsAccount || accountPubkey) && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Loading: Before EOSE received */}
|
||||
{loading && events.length === 0 && !eoseReceived && (
|
||||
<div className="p-4">
|
||||
<TimelineSkeleton count={5} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EOSE received, no events, not streaming */}
|
||||
{eoseReceived && events.length === 0 && !stream && !error && (
|
||||
<div className="text-center text-muted-foreground font-mono text-sm p-4">
|
||||
No events found matching filter
|
||||
</div>
|
||||
)}
|
||||
{/* EOSE received, no events, not streaming */}
|
||||
{eoseReceived && events.length === 0 && !stream && !error && (
|
||||
<div className="text-center text-muted-foreground font-mono text-sm p-4">
|
||||
No events found matching filter
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EOSE received, no events, streaming (live mode) */}
|
||||
{eoseReceived && events.length === 0 && stream && (
|
||||
<div className="text-center text-muted-foreground font-mono text-sm p-4">
|
||||
Listening for new events...
|
||||
</div>
|
||||
)}
|
||||
{/* EOSE received, no events, streaming (live mode) */}
|
||||
{eoseReceived && events.length === 0 && stream && (
|
||||
<div className="text-center text-muted-foreground font-mono text-sm p-4">
|
||||
Listening for new events...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<Virtuoso
|
||||
style={{ height: "100%" }}
|
||||
data={events}
|
||||
computeItemKey={(_index, item) => item.id}
|
||||
itemContent={(_index, event) => <MemoizedFeedEvent event={event} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{events.length > 0 && (
|
||||
<Virtuoso
|
||||
style={{ height: "100%" }}
|
||||
data={events}
|
||||
computeItemKey={(_index, item) => item.id}
|
||||
itemContent={(_index, event) => <MemoizedFeedEvent event={event} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export Dialog */}
|
||||
<Dialog open={showExportDialog} onOpenChange={setShowExportDialog}>
|
||||
|
||||
@@ -137,6 +137,7 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
closeOnEose={window.props.closeOnEose}
|
||||
nip05Authors={window.props.nip05Authors}
|
||||
nip05PTags={window.props.nip05PTags}
|
||||
needsAccount={window.props.needsAccount}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) {
|
||||
<BaseEventContainer event={event}>
|
||||
{/* Show quoted message loading state */}
|
||||
{quotedEventId && !parentEvent && (
|
||||
<InlineReplySkeleton icon={<MessageCircle />} />
|
||||
<InlineReplySkeleton icon={<MessageCircle className="size-3" />} />
|
||||
)}
|
||||
|
||||
{/* Show quoted parent message once loaded (only if it's a chat message) */}
|
||||
|
||||
@@ -21,12 +21,16 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
|
||||
const isImage = isImageMime(metadata.m);
|
||||
const isVideo = isVideoMime(metadata.m);
|
||||
const isAudio = isAudioMime(metadata.m);
|
||||
const isAPK = metadata.m === "application/vnd.android.package-archive";
|
||||
|
||||
// Get additional metadata
|
||||
const fileName =
|
||||
event.tags.find((t) => t[0] === "name")?.[1] || "Unknown file";
|
||||
const summary =
|
||||
event.tags.find((t) => t[0] === "summary")?.[1] || event.content;
|
||||
// For APKs, use: name tag -> content (package identifier) -> fallback
|
||||
// For others, use: name tag -> fallback
|
||||
const nameTag = event.tags.find((t) => t[0] === "name")?.[1];
|
||||
const summaryTag = event.tags.find((t) => t[0] === "summary")?.[1];
|
||||
|
||||
const fileName = nameTag || (isAPK ? event.content : null) || "Unknown file";
|
||||
const summary = summaryTag || (!nameTag && !isAPK ? event.content : null);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
@@ -58,24 +62,15 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
|
||||
) : (
|
||||
/* Non-media file preview */
|
||||
<div className="flex items-center gap-3 p-4 border border-border rounded-lg bg-muted/20">
|
||||
<FileText className="w-8 h-8 text-muted-foreground" />
|
||||
<FileText className="w-8 h-8 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{fileName}</p>
|
||||
{metadata.m && (
|
||||
<p className="text-xs text-muted-foreground">{metadata.m}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{metadata.m}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{metadata.url && (
|
||||
<a
|
||||
href={metadata.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -84,7 +79,7 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
|
||||
{metadata.m && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Type</span>
|
||||
<code className="font-mono text-xs">{metadata.m}</code>
|
||||
<code className="font-mono text-xs truncate">{metadata.m}</code>
|
||||
</>
|
||||
)}
|
||||
{metadata.size && (
|
||||
@@ -109,6 +104,19 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
|
||||
|
||||
{/* Description/Summary */}
|
||||
{summary && <RichText content={summary} className="text-sm" />}
|
||||
|
||||
{/* Download button */}
|
||||
{metadata.url && (
|
||||
<a
|
||||
href={metadata.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary border border-primary/20 rounded-lg hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,9 @@ export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) {
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
{/* Show parent message loading state */}
|
||||
{pointer && !parentEvent && <InlineReplySkeleton icon={<Reply />} />}
|
||||
{pointer && !parentEvent && (
|
||||
<InlineReplySkeleton icon={<Reply className="size-3" />} />
|
||||
)}
|
||||
|
||||
{/* Show parent message once loaded */}
|
||||
{pointer && parentEvent && (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { KindRenderer } from "./index";
|
||||
import { RichText } from "../RichText";
|
||||
import { EventCardSkeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 9735 - Zap Receipts
|
||||
@@ -85,31 +86,25 @@ export function Kind9735Renderer({ event }: BaseEventProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embedded zapped event (if loaded) */}
|
||||
{zappedEvent && (
|
||||
<div className="border border-muted">
|
||||
<EmbeddedEvent event={zappedEvent} />
|
||||
{/* Zapped content with loading states */}
|
||||
{addressPointer && !zappedAddress && (
|
||||
<div className="border border-muted p-2">
|
||||
<EventCardSkeleton variant="compact" showActions={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embedded zapped address (if loaded and different from event) */}
|
||||
{zappedAddress && (
|
||||
{addressPointer && zappedAddress && (
|
||||
<div className="border border-muted">
|
||||
<EmbeddedEvent event={zappedAddress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state for event pointer */}
|
||||
{eventPointer && !zappedEvent && (
|
||||
<div className="border border-muted p-2 text-xs text-muted-foreground">
|
||||
Loading zapped event...
|
||||
{!addressPointer && eventPointer && !zappedEvent && (
|
||||
<div className="border border-muted p-2">
|
||||
<EventCardSkeleton variant="compact" showActions={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state for address pointer */}
|
||||
{addressPointer && !zappedAddress && (
|
||||
<div className="border border-muted p-2 text-xs text-muted-foreground">
|
||||
Loading zapped address...
|
||||
{!addressPointer && eventPointer && zappedEvent && (
|
||||
<div className="border border-muted">
|
||||
<EmbeddedEvent event={zappedEvent} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user