mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +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>
|
||||
|
||||
350
src/lib/nostr-utils.test.ts
Normal file
350
src/lib/nostr-utils.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveFilterAliases } from "./nostr-utils";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
|
||||
describe("resolveFilterAliases", () => {
|
||||
describe("$me alias resolution", () => {
|
||||
it("should replace $me with account pubkey in authors", () => {
|
||||
const filter: NostrFilter = { authors: ["$me"] };
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const result = resolveFilterAliases(filter, accountPubkey, []);
|
||||
|
||||
expect(result.authors).toEqual([accountPubkey]);
|
||||
expect(result.authors).not.toContain("$me");
|
||||
});
|
||||
|
||||
it("should replace $me with account pubkey in #p tags", () => {
|
||||
const filter: NostrFilter = { "#p": ["$me"] };
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const result = resolveFilterAliases(filter, accountPubkey, []);
|
||||
|
||||
expect(result["#p"]).toEqual([accountPubkey]);
|
||||
expect(result["#p"]).not.toContain("$me");
|
||||
});
|
||||
|
||||
it("should handle $me when no account is set", () => {
|
||||
const filter: NostrFilter = { authors: ["$me"] };
|
||||
const result = resolveFilterAliases(filter, undefined, []);
|
||||
|
||||
expect(result.authors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should preserve other pubkeys when resolving $me", () => {
|
||||
const hex = "b".repeat(64);
|
||||
const filter: NostrFilter = { authors: ["$me", hex] };
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const result = resolveFilterAliases(filter, accountPubkey, []);
|
||||
|
||||
expect(result.authors).toContain(accountPubkey);
|
||||
expect(result.authors).toContain(hex);
|
||||
expect(result.authors).not.toContain("$me");
|
||||
});
|
||||
});
|
||||
|
||||
describe("$contacts alias resolution", () => {
|
||||
it("should replace $contacts with contact pubkeys in authors", () => {
|
||||
const filter: NostrFilter = { authors: ["$contacts"] };
|
||||
const contacts = ["a".repeat(64), "b".repeat(64), "c".repeat(64)];
|
||||
const result = resolveFilterAliases(filter, undefined, contacts);
|
||||
|
||||
expect(result.authors).toEqual(contacts);
|
||||
expect(result.authors).not.toContain("$contacts");
|
||||
});
|
||||
|
||||
it("should replace $contacts with contact pubkeys in #p tags", () => {
|
||||
const filter: NostrFilter = { "#p": ["$contacts"] };
|
||||
const contacts = ["a".repeat(64), "b".repeat(64)];
|
||||
const result = resolveFilterAliases(filter, undefined, contacts);
|
||||
|
||||
expect(result["#p"]).toEqual(contacts);
|
||||
expect(result["#p"]).not.toContain("$contacts");
|
||||
});
|
||||
|
||||
it("should handle $contacts with empty contact list", () => {
|
||||
const filter: NostrFilter = { authors: ["$contacts"] };
|
||||
const result = resolveFilterAliases(filter, undefined, []);
|
||||
|
||||
expect(result.authors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should preserve other pubkeys when resolving $contacts", () => {
|
||||
const hex = "d".repeat(64);
|
||||
const filter: NostrFilter = { authors: ["$contacts", hex] };
|
||||
const contacts = ["a".repeat(64), "b".repeat(64)];
|
||||
const result = resolveFilterAliases(filter, undefined, contacts);
|
||||
|
||||
expect(result.authors).toContain(hex);
|
||||
expect(result.authors).toContain(contacts[0]);
|
||||
expect(result.authors).toContain(contacts[1]);
|
||||
expect(result.authors).not.toContain("$contacts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined $me and $contacts", () => {
|
||||
it("should resolve both $me and $contacts in authors", () => {
|
||||
const filter: NostrFilter = { authors: ["$me", "$contacts"] };
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const contacts = ["b".repeat(64), "c".repeat(64)];
|
||||
const result = resolveFilterAliases(filter, accountPubkey, contacts);
|
||||
|
||||
expect(result.authors).toContain(accountPubkey);
|
||||
expect(result.authors).toContain(contacts[0]);
|
||||
expect(result.authors).toContain(contacts[1]);
|
||||
expect(result.authors).not.toContain("$me");
|
||||
expect(result.authors).not.toContain("$contacts");
|
||||
});
|
||||
|
||||
it("should resolve aliases in both authors and #p tags", () => {
|
||||
const filter: NostrFilter = {
|
||||
authors: ["$me"],
|
||||
"#p": ["$contacts"],
|
||||
};
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const contacts = ["b".repeat(64), "c".repeat(64)];
|
||||
const result = resolveFilterAliases(filter, accountPubkey, contacts);
|
||||
|
||||
expect(result.authors).toEqual([accountPubkey]);
|
||||
expect(result["#p"]).toEqual(contacts);
|
||||
});
|
||||
|
||||
it("should handle mix of aliases and regular pubkeys", () => {
|
||||
const hex1 = "d".repeat(64);
|
||||
const hex2 = "e".repeat(64);
|
||||
const filter: NostrFilter = {
|
||||
authors: ["$me", hex1, "$contacts"],
|
||||
"#p": [hex2, "$me"],
|
||||
};
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const contacts = ["b".repeat(64), "c".repeat(64)];
|
||||
const result = resolveFilterAliases(filter, accountPubkey, contacts);
|
||||
|
||||
expect(result.authors).toContain(accountPubkey);
|
||||
expect(result.authors).toContain(hex1);
|
||||
expect(result.authors).toContain(contacts[0]);
|
||||
expect(result.authors).toContain(contacts[1]);
|
||||
expect(result["#p"]).toContain(hex2);
|
||||
expect(result["#p"]).toContain(accountPubkey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplication", () => {
|
||||
it("should deduplicate when $me is in contacts", () => {
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const contacts = [accountPubkey, "b".repeat(64), "c".repeat(64)];
|
||||
const filter: NostrFilter = { authors: ["$me", "$contacts"] };
|
||||
const result = resolveFilterAliases(filter, accountPubkey, contacts);
|
||||
|
||||
// Account pubkey should appear once (deduplicated)
|
||||
const accountCount = result.authors?.filter(
|
||||
(a) => a === accountPubkey,
|
||||
).length;
|
||||
expect(accountCount).toBe(1);
|
||||
expect(result.authors?.length).toBe(3); // account + 2 other contacts
|
||||
});
|
||||
|
||||
it("should deduplicate regular pubkeys that appear multiple times", () => {
|
||||
const hex = "d".repeat(64);
|
||||
const filter: NostrFilter = { authors: [hex, hex, hex] };
|
||||
const result = resolveFilterAliases(filter, undefined, []);
|
||||
|
||||
expect(result.authors).toEqual([hex]);
|
||||
});
|
||||
|
||||
it("should deduplicate across resolved contacts and explicit pubkeys", () => {
|
||||
const hex1 = "a".repeat(64);
|
||||
const hex2 = "b".repeat(64);
|
||||
const contacts = [hex1, hex2, "c".repeat(64)];
|
||||
const filter: NostrFilter = { authors: ["$contacts", hex1, hex2] };
|
||||
const result = resolveFilterAliases(filter, undefined, contacts);
|
||||
|
||||
// Each pubkey should appear once
|
||||
expect(result.authors?.filter((a) => a === hex1).length).toBe(1);
|
||||
expect(result.authors?.filter((a) => a === hex2).length).toBe(1);
|
||||
expect(result.authors?.length).toBe(3); // 3 unique contacts
|
||||
});
|
||||
});
|
||||
|
||||
describe("filter preservation", () => {
|
||||
it("should preserve other filter properties", () => {
|
||||
const filter: NostrFilter = {
|
||||
authors: ["$me"],
|
||||
kinds: [1, 3, 7],
|
||||
limit: 50,
|
||||
since: 1234567890,
|
||||
"#t": ["nostr", "bitcoin"],
|
||||
};
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const result = resolveFilterAliases(filter, accountPubkey, []);
|
||||
|
||||
expect(result.kinds).toEqual([1, 3, 7]);
|
||||
expect(result.limit).toBe(50);
|
||||
expect(result.since).toBe(1234567890);
|
||||
expect(result["#t"]).toEqual(["nostr", "bitcoin"]);
|
||||
});
|
||||
|
||||
it("should not modify original filter", () => {
|
||||
const filter: NostrFilter = { authors: ["$me"] };
|
||||
const accountPubkey = "a".repeat(64);
|
||||
resolveFilterAliases(filter, accountPubkey, []);
|
||||
|
||||
// Original filter should still have $me
|
||||
expect(filter.authors).toContain("$me");
|
||||
});
|
||||
|
||||
it("should handle filters without aliases", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const filter: NostrFilter = {
|
||||
authors: [hex],
|
||||
kinds: [1],
|
||||
};
|
||||
const result = resolveFilterAliases(filter, "b".repeat(64), []);
|
||||
|
||||
expect(result.authors).toEqual([hex]);
|
||||
expect(result.kinds).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle empty filter", () => {
|
||||
const filter: NostrFilter = {};
|
||||
const result = resolveFilterAliases(filter, "a".repeat(64), []);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle undefined authors array", () => {
|
||||
const filter: NostrFilter = { kinds: [1] };
|
||||
const result = resolveFilterAliases(filter, "a".repeat(64), []);
|
||||
|
||||
expect(result.authors).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle undefined #p array", () => {
|
||||
const filter: NostrFilter = { authors: ["$me"] };
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const result = resolveFilterAliases(filter, accountPubkey, []);
|
||||
|
||||
expect(result["#p"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle large contact lists", () => {
|
||||
const contacts = Array.from({ length: 5000 }, (_, i) =>
|
||||
i.toString(16).padStart(64, "0"),
|
||||
);
|
||||
const filter: NostrFilter = { authors: ["$contacts"] };
|
||||
const result = resolveFilterAliases(filter, undefined, contacts);
|
||||
|
||||
expect(result.authors?.length).toBe(5000);
|
||||
});
|
||||
|
||||
it("should handle mixed case aliases (should not match)", () => {
|
||||
// Aliases are case-sensitive and should be lowercase
|
||||
const filter: NostrFilter = { authors: ["$Me", "$CONTACTS"] };
|
||||
const result = resolveFilterAliases(filter, "a".repeat(64), [
|
||||
"b".repeat(64),
|
||||
]);
|
||||
|
||||
// These should NOT be resolved (case mismatch)
|
||||
expect(result.authors).toContain("$Me");
|
||||
expect(result.authors).toContain("$CONTACTS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uppercase #P tag resolution", () => {
|
||||
it("should replace $me with account pubkey in #P tags", () => {
|
||||
const filter: NostrFilter = { "#P": ["$me"] };
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const result = resolveFilterAliases(filter, accountPubkey, []);
|
||||
|
||||
expect(result["#P"]).toEqual([accountPubkey]);
|
||||
expect(result["#P"]).not.toContain("$me");
|
||||
});
|
||||
|
||||
it("should replace $contacts with contact pubkeys in #P tags", () => {
|
||||
const filter: NostrFilter = { "#P": ["$contacts"] };
|
||||
const contacts = ["a".repeat(64), "b".repeat(64), "c".repeat(64)];
|
||||
const result = resolveFilterAliases(filter, undefined, contacts);
|
||||
|
||||
expect(result["#P"]).toEqual(contacts);
|
||||
expect(result["#P"]).not.toContain("$contacts");
|
||||
});
|
||||
|
||||
it("should handle $me when no account is set in #P", () => {
|
||||
const filter: NostrFilter = { "#P": ["$me"] };
|
||||
const result = resolveFilterAliases(filter, undefined, []);
|
||||
|
||||
expect(result["#P"]).toEqual([]);
|
||||
});
|
||||
|
||||
it("should preserve other pubkeys when resolving $me in #P", () => {
|
||||
const hex = "b".repeat(64);
|
||||
const filter: NostrFilter = { "#P": ["$me", hex] };
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const result = resolveFilterAliases(filter, accountPubkey, []);
|
||||
|
||||
expect(result["#P"]).toContain(accountPubkey);
|
||||
expect(result["#P"]).toContain(hex);
|
||||
expect(result["#P"]).not.toContain("$me");
|
||||
});
|
||||
|
||||
it("should handle mix of $me, $contacts, and regular pubkeys in #P", () => {
|
||||
const hex1 = "d".repeat(64);
|
||||
const filter: NostrFilter = { "#P": ["$me", hex1, "$contacts"] };
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const contacts = ["b".repeat(64), "c".repeat(64)];
|
||||
const result = resolveFilterAliases(filter, accountPubkey, contacts);
|
||||
|
||||
expect(result["#P"]).toContain(accountPubkey);
|
||||
expect(result["#P"]).toContain(hex1);
|
||||
expect(result["#P"]).toContain(contacts[0]);
|
||||
expect(result["#P"]).toContain(contacts[1]);
|
||||
});
|
||||
|
||||
it("should deduplicate when $me is in contacts for #P", () => {
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const contacts = [accountPubkey, "b".repeat(64), "c".repeat(64)];
|
||||
const filter: NostrFilter = { "#P": ["$me", "$contacts"] };
|
||||
const result = resolveFilterAliases(filter, accountPubkey, contacts);
|
||||
|
||||
// Account pubkey should appear once (deduplicated)
|
||||
const accountCount = result["#P"]?.filter(
|
||||
(a) => a === accountPubkey,
|
||||
).length;
|
||||
expect(accountCount).toBe(1);
|
||||
expect(result["#P"]?.length).toBe(3); // account + 2 other contacts
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed #p and #P tag resolution", () => {
|
||||
it("should resolve aliases in both #p and #P independently", () => {
|
||||
const filter: NostrFilter = {
|
||||
"#p": ["$me"],
|
||||
"#P": ["$contacts"],
|
||||
};
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const contacts = ["b".repeat(64), "c".repeat(64)];
|
||||
const result = resolveFilterAliases(filter, accountPubkey, contacts);
|
||||
|
||||
expect(result["#p"]).toEqual([accountPubkey]);
|
||||
expect(result["#P"]).toEqual(contacts);
|
||||
});
|
||||
|
||||
it("should handle same aliases in both tags without interference", () => {
|
||||
const filter: NostrFilter = {
|
||||
"#p": ["$me", "$contacts"],
|
||||
"#P": ["$me", "$contacts"],
|
||||
};
|
||||
const accountPubkey = "a".repeat(64);
|
||||
const contacts = ["b".repeat(64), "c".repeat(64)];
|
||||
const result = resolveFilterAliases(filter, accountPubkey, contacts);
|
||||
|
||||
// Both should have same resolved values
|
||||
expect(result["#p"]).toContain(accountPubkey);
|
||||
expect(result["#p"]).toContain(contacts[0]);
|
||||
expect(result["#p"]).toContain(contacts[1]);
|
||||
expect(result["#P"]).toContain(accountPubkey);
|
||||
expect(result["#P"]).toContain(contacts[0]);
|
||||
expect(result["#P"]).toContain(contacts[1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ProfileContent } from "applesauce-core/helpers";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
|
||||
export function derivePlaceholderName(pubkey: string): string {
|
||||
return `${pubkey.slice(0, 4)}:${pubkey.slice(-4)}`;
|
||||
@@ -23,3 +24,80 @@ export function getDisplayName(
|
||||
}
|
||||
return derivePlaceholderName(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve $me and $contacts aliases in a Nostr filter
|
||||
* @param filter - Filter that may contain $me or $contacts aliases
|
||||
* @param accountPubkey - Current user's pubkey (for $me resolution)
|
||||
* @param contacts - Array of contact pubkeys (for $contacts resolution)
|
||||
* @returns Resolved filter with aliases replaced by actual pubkeys
|
||||
*/
|
||||
export function resolveFilterAliases(
|
||||
filter: NostrFilter,
|
||||
accountPubkey: string | undefined,
|
||||
contacts: string[],
|
||||
): NostrFilter {
|
||||
const resolved = { ...filter };
|
||||
|
||||
// Resolve aliases in authors array
|
||||
if (resolved.authors && resolved.authors.length > 0) {
|
||||
const resolvedAuthors: string[] = [];
|
||||
|
||||
for (const author of resolved.authors) {
|
||||
if (author === "$me") {
|
||||
if (accountPubkey) {
|
||||
resolvedAuthors.push(accountPubkey);
|
||||
}
|
||||
} else if (author === "$contacts") {
|
||||
resolvedAuthors.push(...contacts);
|
||||
} else {
|
||||
resolvedAuthors.push(author);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
resolved.authors = Array.from(new Set(resolvedAuthors));
|
||||
}
|
||||
|
||||
// Resolve aliases in #p tags array
|
||||
if (resolved["#p"] && resolved["#p"].length > 0) {
|
||||
const resolvedPTags: string[] = [];
|
||||
|
||||
for (const pTag of resolved["#p"]) {
|
||||
if (pTag === "$me") {
|
||||
if (accountPubkey) {
|
||||
resolvedPTags.push(accountPubkey);
|
||||
}
|
||||
} else if (pTag === "$contacts") {
|
||||
resolvedPTags.push(...contacts);
|
||||
} else {
|
||||
resolvedPTags.push(pTag);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
resolved["#p"] = Array.from(new Set(resolvedPTags));
|
||||
}
|
||||
|
||||
// Resolve aliases in #P tags array (uppercase P, e.g., zap senders)
|
||||
if (resolved["#P"] && resolved["#P"].length > 0) {
|
||||
const resolvedPTagsUppercase: string[] = [];
|
||||
|
||||
for (const pTag of resolved["#P"]) {
|
||||
if (pTag === "$me") {
|
||||
if (accountPubkey) {
|
||||
resolvedPTagsUppercase.push(accountPubkey);
|
||||
}
|
||||
} else if (pTag === "$contacts") {
|
||||
resolvedPTagsUppercase.push(...contacts);
|
||||
} else {
|
||||
resolvedPTagsUppercase.push(pTag);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
resolved["#P"] = Array.from(new Set(resolvedPTagsUppercase));
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -247,6 +247,98 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("uppercase P tag flag (-P)", () => {
|
||||
it("should parse hex pubkey for #P tag", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const result = parseReqCommand(["-P", hex]);
|
||||
expect(result.filter["#P"]).toEqual([hex]);
|
||||
});
|
||||
|
||||
it("should parse npub for #P tag", () => {
|
||||
const npub =
|
||||
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
|
||||
const result = parseReqCommand(["-P", npub]);
|
||||
expect(result.filter["#P"]).toEqual([
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse nprofile for #P tag", () => {
|
||||
const nprofile =
|
||||
"nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p";
|
||||
const result = parseReqCommand(["-P", nprofile]);
|
||||
expect(result.filter["#P"]).toEqual([
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse comma-separated pubkeys for #P", () => {
|
||||
const hex1 = "a".repeat(64);
|
||||
const hex2 = "b".repeat(64);
|
||||
const result = parseReqCommand(["-P", `${hex1},${hex2}`]);
|
||||
expect(result.filter["#P"]).toEqual([hex1, hex2]);
|
||||
});
|
||||
|
||||
it("should accumulate NIP-05 identifiers for #P tags", () => {
|
||||
const result = parseReqCommand([
|
||||
"-P",
|
||||
"user@domain.com,alice@example.com",
|
||||
]);
|
||||
expect(result.nip05PTagsUppercase).toEqual([
|
||||
"user@domain.com",
|
||||
"alice@example.com",
|
||||
]);
|
||||
expect(result.filter["#P"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle mixed hex, npub, and NIP-05 for #P tags", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const npub =
|
||||
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
|
||||
const result = parseReqCommand(["-P", `${hex},${npub},user@domain.com`]);
|
||||
expect(result.filter["#P"]).toEqual([
|
||||
hex,
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
||||
]);
|
||||
expect(result.nip05PTagsUppercase).toEqual(["user@domain.com"]);
|
||||
});
|
||||
|
||||
it("should deduplicate #P tags", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const result = parseReqCommand(["-P", `${hex},${hex}`]);
|
||||
expect(result.filter["#P"]).toEqual([hex]);
|
||||
});
|
||||
|
||||
it("should handle $me alias in #P tags", () => {
|
||||
const result = parseReqCommand(["-P", "$me"]);
|
||||
expect(result.filter["#P"]).toContain("$me");
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle $contacts alias in #P tags", () => {
|
||||
const result = parseReqCommand(["-P", "$contacts"]);
|
||||
expect(result.filter["#P"]).toContain("$contacts");
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle mixed aliases and pubkeys in #P", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const result = parseReqCommand(["-P", `$me,${hex},$contacts`]);
|
||||
expect(result.filter["#P"]).toContain("$me");
|
||||
expect(result.filter["#P"]).toContain(hex);
|
||||
expect(result.filter["#P"]).toContain("$contacts");
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should differentiate between -p and -P flags", () => {
|
||||
const hex1 = "a".repeat(64);
|
||||
const hex2 = "b".repeat(64);
|
||||
const result = parseReqCommand(["-p", hex1, "-P", hex2]);
|
||||
expect(result.filter["#p"]).toEqual([hex1]);
|
||||
expect(result.filter["#P"]).toEqual([hex2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashtag flag (-t)", () => {
|
||||
it("should parse single hashtag", () => {
|
||||
const result = parseReqCommand(["-t", "nostr"]);
|
||||
@@ -588,6 +680,147 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("$me and $contacts aliases", () => {
|
||||
describe("$me alias in authors (-a)", () => {
|
||||
it("should detect $me in authors", () => {
|
||||
const result = parseReqCommand(["-a", "$me"]);
|
||||
expect(result.filter.authors).toContain("$me");
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle $me with other pubkeys", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const result = parseReqCommand(["-a", `$me,${hex}`]);
|
||||
expect(result.filter.authors).toContain("$me");
|
||||
expect(result.filter.authors).toContain(hex);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should deduplicate $me", () => {
|
||||
const result = parseReqCommand(["-a", "$me,$me"]);
|
||||
expect(result.filter.authors).toEqual(["$me"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("$contacts alias in authors (-a)", () => {
|
||||
it("should detect $contacts in authors", () => {
|
||||
const result = parseReqCommand(["-a", "$contacts"]);
|
||||
expect(result.filter.authors).toContain("$contacts");
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle $contacts with other pubkeys", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const result = parseReqCommand(["-a", `$contacts,${hex}`]);
|
||||
expect(result.filter.authors).toContain("$contacts");
|
||||
expect(result.filter.authors).toContain(hex);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle $me and $contacts together", () => {
|
||||
const result = parseReqCommand(["-a", "$me,$contacts"]);
|
||||
expect(result.filter.authors).toContain("$me");
|
||||
expect(result.filter.authors).toContain("$contacts");
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("$me alias in #p tags (-p)", () => {
|
||||
it("should detect $me in #p tags", () => {
|
||||
const result = parseReqCommand(["-p", "$me"]);
|
||||
expect(result.filter["#p"]).toContain("$me");
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle $me with other pubkeys in #p", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const result = parseReqCommand(["-p", `$me,${hex}`]);
|
||||
expect(result.filter["#p"]).toContain("$me");
|
||||
expect(result.filter["#p"]).toContain(hex);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("$contacts alias in #p tags (-p)", () => {
|
||||
it("should detect $contacts in #p tags", () => {
|
||||
const result = parseReqCommand(["-p", "$contacts"]);
|
||||
expect(result.filter["#p"]).toContain("$contacts");
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle $contacts with other pubkeys in #p", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const result = parseReqCommand(["-p", `$contacts,${hex}`]);
|
||||
expect(result.filter["#p"]).toContain("$contacts");
|
||||
expect(result.filter["#p"]).toContain(hex);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle $me and $contacts together in #p", () => {
|
||||
const result = parseReqCommand(["-p", "$me,$contacts"]);
|
||||
expect(result.filter["#p"]).toContain("$me");
|
||||
expect(result.filter["#p"]).toContain("$contacts");
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed aliases across -a and -p", () => {
|
||||
it("should set needsAccount if alias in authors only", () => {
|
||||
const result = parseReqCommand(["-a", "$me", "-k", "1"]);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should set needsAccount if alias in #p only", () => {
|
||||
const result = parseReqCommand(["-p", "$contacts", "-k", "1"]);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should set needsAccount if aliases in both", () => {
|
||||
const result = parseReqCommand(["-a", "$me", "-p", "$contacts"]);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should not set needsAccount without aliases", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const result = parseReqCommand(["-a", hex, "-k", "1"]);
|
||||
expect(result.needsAccount).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex scenarios with aliases", () => {
|
||||
it("should handle aliases with other filter types", () => {
|
||||
const result = parseReqCommand([
|
||||
"-k",
|
||||
"1",
|
||||
"-a",
|
||||
"$contacts",
|
||||
"--since",
|
||||
"24h",
|
||||
"-l",
|
||||
"50",
|
||||
]);
|
||||
expect(result.filter.kinds).toEqual([1]);
|
||||
expect(result.filter.authors).toContain("$contacts");
|
||||
expect(result.filter.since).toBeDefined();
|
||||
expect(result.filter.limit).toBe(50);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle mixed pubkeys, NIP-05, and aliases", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const result = parseReqCommand([
|
||||
"-a",
|
||||
`${hex},$me,user@domain.com,$contacts`,
|
||||
]);
|
||||
expect(result.filter.authors).toContain(hex);
|
||||
expect(result.filter.authors).toContain("$me");
|
||||
expect(result.filter.authors).toContain("$contacts");
|
||||
expect(result.nip05Authors).toEqual(["user@domain.com"]);
|
||||
expect(result.needsAccount).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty args", () => {
|
||||
const result = parseReqCommand([]);
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface ParsedReqCommand {
|
||||
closeOnEose?: boolean;
|
||||
nip05Authors?: string[]; // NIP-05 identifiers that need async resolution
|
||||
nip05PTags?: string[]; // NIP-05 identifiers for #p tags that need async resolution
|
||||
nip05PTagsUppercase?: string[]; // NIP-05 identifiers for #P tags that need async resolution
|
||||
needsAccount?: boolean; // True if filter contains $me or $contacts aliases
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +45,7 @@ function parseCommaSeparated<T>(
|
||||
/**
|
||||
* Parse REQ command arguments into a Nostr filter
|
||||
* Supports:
|
||||
* - Filters: -k (kinds), -a (authors: hex/npub/nprofile/NIP-05), -l (limit), -e (#e), -p (#p: hex/npub/nprofile/NIP-05), -t (#t), -d (#d), --tag/-T (any #tag)
|
||||
* - Filters: -k (kinds), -a (authors: hex/npub/nprofile/NIP-05), -l (limit), -e (#e), -p (#p: hex/npub/nprofile/NIP-05), -P (#P: hex/npub/nprofile/NIP-05), -t (#t), -d (#d), --tag/-T (any #tag)
|
||||
* - Time: --since, --until
|
||||
* - Search: --search
|
||||
* - Relays: wss://relay.com or relay.com (auto-adds wss://), nprofile relay hints are automatically extracted
|
||||
@@ -54,12 +56,14 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
|
||||
const relays: string[] = [];
|
||||
const nip05Authors = new Set<string>();
|
||||
const nip05PTags = new Set<string>();
|
||||
const nip05PTagsUppercase = new Set<string>();
|
||||
|
||||
// Use sets for deduplication during accumulation
|
||||
const kinds = new Set<number>();
|
||||
const authors = new Set<string>();
|
||||
const eventIds = new Set<string>();
|
||||
const pTags = new Set<string>();
|
||||
const pTagsUppercase = new Set<string>();
|
||||
const tTags = new Set<string>();
|
||||
const dTags = new Set<string>();
|
||||
|
||||
@@ -114,7 +118,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
|
||||
|
||||
case "-a":
|
||||
case "--author": {
|
||||
// Support comma-separated authors: -a npub1...,npub2...,user@domain.com
|
||||
// Support comma-separated authors: -a npub1...,npub2...,user@domain.com,$me,$contacts
|
||||
if (!nextArg) {
|
||||
i++;
|
||||
break;
|
||||
@@ -123,8 +127,12 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
|
||||
const values = nextArg.split(",").map((a) => a.trim());
|
||||
for (const authorStr of values) {
|
||||
if (!authorStr) continue;
|
||||
// Check if it's a NIP-05 identifier
|
||||
if (isNip05(authorStr)) {
|
||||
// Check for $me and $contacts aliases
|
||||
if (authorStr === "$me" || authorStr === "$contacts") {
|
||||
authors.add(authorStr);
|
||||
addedAny = true;
|
||||
} else if (isNip05(authorStr)) {
|
||||
// Check if it's a NIP-05 identifier
|
||||
nip05Authors.add(authorStr);
|
||||
addedAny = true;
|
||||
} else {
|
||||
@@ -171,7 +179,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
|
||||
}
|
||||
|
||||
case "-p": {
|
||||
// Support comma-separated pubkeys: -p npub1...,npub2...,user@domain.com
|
||||
// Support comma-separated pubkeys: -p npub1...,npub2...,user@domain.com,$me,$contacts
|
||||
if (!nextArg) {
|
||||
i++;
|
||||
break;
|
||||
@@ -180,8 +188,12 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
|
||||
const values = nextArg.split(",").map((p) => p.trim());
|
||||
for (const pubkeyStr of values) {
|
||||
if (!pubkeyStr) continue;
|
||||
// Check if it's a NIP-05 identifier
|
||||
if (isNip05(pubkeyStr)) {
|
||||
// Check for $me and $contacts aliases
|
||||
if (pubkeyStr === "$me" || pubkeyStr === "$contacts") {
|
||||
pTags.add(pubkeyStr);
|
||||
addedAny = true;
|
||||
} else if (isNip05(pubkeyStr)) {
|
||||
// Check if it's a NIP-05 identifier
|
||||
nip05PTags.add(pubkeyStr);
|
||||
addedAny = true;
|
||||
} else {
|
||||
@@ -200,6 +212,41 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
|
||||
break;
|
||||
}
|
||||
|
||||
case "-P": {
|
||||
// Uppercase P tag (e.g., zap sender in kind 9735)
|
||||
// Support comma-separated pubkeys: -P npub1...,npub2...,$me,$contacts
|
||||
if (!nextArg) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
let addedAny = false;
|
||||
const values = nextArg.split(",").map((p) => p.trim());
|
||||
for (const pubkeyStr of values) {
|
||||
if (!pubkeyStr) continue;
|
||||
// Check for $me and $contacts aliases
|
||||
if (pubkeyStr === "$me" || pubkeyStr === "$contacts") {
|
||||
pTagsUppercase.add(pubkeyStr);
|
||||
addedAny = true;
|
||||
} else if (isNip05(pubkeyStr)) {
|
||||
// Check if it's a NIP-05 identifier
|
||||
nip05PTagsUppercase.add(pubkeyStr);
|
||||
addedAny = true;
|
||||
} else {
|
||||
const result = parseNpubOrHex(pubkeyStr);
|
||||
if (result.pubkey) {
|
||||
pTagsUppercase.add(result.pubkey);
|
||||
addedAny = true;
|
||||
// Add relay hints from nprofile (normalized)
|
||||
if (result.relays) {
|
||||
relays.push(...result.relays.map(normalizeRelayURL));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += addedAny ? 2 : 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case "-t": {
|
||||
// Support comma-separated hashtags: -t nostr,bitcoin,lightning
|
||||
if (nextArg) {
|
||||
@@ -319,6 +366,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
|
||||
if (authors.size > 0) filter.authors = Array.from(authors);
|
||||
if (eventIds.size > 0) filter["#e"] = Array.from(eventIds);
|
||||
if (pTags.size > 0) filter["#p"] = Array.from(pTags);
|
||||
if (pTagsUppercase.size > 0) filter["#P"] = Array.from(pTagsUppercase);
|
||||
if (tTags.size > 0) filter["#t"] = Array.from(tTags);
|
||||
if (dTags.size > 0) filter["#d"] = Array.from(dTags);
|
||||
|
||||
@@ -329,12 +377,22 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if filter contains $me or $contacts aliases
|
||||
const needsAccount =
|
||||
filter.authors?.some((a) => a === "$me" || a === "$contacts") ||
|
||||
filter["#p"]?.some((p) => p === "$me" || p === "$contacts") ||
|
||||
filter["#P"]?.some((p) => p === "$me" || p === "$contacts") ||
|
||||
false;
|
||||
|
||||
return {
|
||||
filter,
|
||||
relays: relays.length > 0 ? relays : undefined,
|
||||
closeOnEose,
|
||||
nip05Authors: nip05Authors.size > 0 ? Array.from(nip05Authors) : undefined,
|
||||
nip05PTags: nip05PTags.size > 0 ? Array.from(nip05PTags) : undefined,
|
||||
nip05PTagsUppercase:
|
||||
nip05PTagsUppercase.size > 0 ? Array.from(nip05PTagsUppercase) : undefined,
|
||||
needsAccount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
section: "1",
|
||||
synopsis: "req [options] [relay...]",
|
||||
description:
|
||||
"Query Nostr relays using filters. Constructs and executes Nostr REQ messages to fetch events matching specified criteria. Supports filtering by kind, author, tags, time ranges, and content search.",
|
||||
"Query Nostr relays using filters. Constructs and executes Nostr REQ messages to fetch events matching specified criteria. Supports filtering by kind, author, tags, time ranges, and content search. Use $me and $contacts aliases for queries based on your active account.",
|
||||
options: [
|
||||
{
|
||||
flag: "-k, --kind <number>",
|
||||
@@ -159,9 +159,9 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
"Filter by event kind (e.g., 0=metadata, 1=note, 7=reaction). Supports comma-separated values: -k 1,3,7",
|
||||
},
|
||||
{
|
||||
flag: "-a, --author <npub|hex|nip05>",
|
||||
flag: "-a, --author <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by author pubkey (supports npub, hex, NIP-05 identifier, or bare domain). Supports comma-separated values: -a npub1...,user@domain.com",
|
||||
"Filter by author pubkey (supports npub, hex, NIP-05 identifier, bare domain, $me, or $contacts). Supports comma-separated values: -a npub1...,user@domain.com,$me",
|
||||
},
|
||||
{
|
||||
flag: "-l, --limit <number>",
|
||||
@@ -173,9 +173,14 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
"Filter by referenced event ID (#e tag). Supports comma-separated values: -e id1,id2,id3",
|
||||
},
|
||||
{
|
||||
flag: "-p <npub|hex|nip05>",
|
||||
flag: "-p <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, or bare domain). Supports comma-separated values: -p npub1...,npub2...",
|
||||
"Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, bare domain, $me, or $contacts). Supports comma-separated values: -p npub1...,npub2...,$contacts",
|
||||
},
|
||||
{
|
||||
flag: "-P <npub|hex|nip05|$me|$contacts>",
|
||||
description:
|
||||
"Filter by zap sender (#P tag, supports npub, hex, NIP-05, bare domain, $me, or $contacts). Supports comma-separated values: -P npub1...,npub2...,$me. Useful for finding zaps sent by specific users.",
|
||||
},
|
||||
{
|
||||
flag: "-t <hashtag>",
|
||||
@@ -224,6 +229,13 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
"req -k 1 -a user@domain.com Get notes from NIP-05 identifier",
|
||||
"req -k 1 -a dergigi.com Get notes from bare domain (resolves to _@dergigi.com)",
|
||||
"req -k 1 -a npub1...,npub2... Get notes from multiple authors",
|
||||
"req -a $me Get all events authored by you",
|
||||
"req -k 1 -a $contacts --since 24h Get notes from your contacts in last 24h",
|
||||
"req -p $me -k 1,7 Get replies and reactions to your posts",
|
||||
"req -k 1 -a $me -a $contacts Get notes from you and your contacts",
|
||||
"req -k 9735 -p $me --since 7d Get zaps you received in last 7 days",
|
||||
"req -k 9735 -P $me --since 7d Get zaps you sent in last 7 days",
|
||||
"req -k 9735 -P $contacts Get zaps sent by your contacts",
|
||||
"req -k 1 -p verbiricha@habla.news Get notes mentioning NIP-05 user",
|
||||
"req -k 1 --since 1h relay.damus.io Get notes from last hour",
|
||||
"req -k 1 --close-on-eose Get recent notes and close after EOSE",
|
||||
@@ -250,6 +262,7 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
const allNip05 = [
|
||||
...(parsed.nip05Authors || []),
|
||||
...(parsed.nip05PTags || []),
|
||||
...(parsed.nip05PTagsUppercase || []),
|
||||
];
|
||||
|
||||
if (allNip05.length > 0) {
|
||||
@@ -276,6 +289,17 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add resolved #P tags to filter
|
||||
if (parsed.nip05PTagsUppercase) {
|
||||
for (const nip05 of parsed.nip05PTagsUppercase) {
|
||||
const pubkey = resolved.get(nip05);
|
||||
if (pubkey) {
|
||||
if (!parsed.filter["#P"]) parsed.filter["#P"] = [];
|
||||
parsed.filter["#P"].push(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
|
||||
Reference in New Issue
Block a user