Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 82 additions & 10 deletions src/components/ReqViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ import {
getStatusColor,
shouldAnimate,
} from "@/lib/req-state-machine";
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
import {
resolveFilterAliases,
getTagValues,
getAllTagValues,
} from "@/lib/nostr-utils";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { MemoizedCompactEventRow } from "./nostr/CompactEventRow";
import type { ViewMode } from "@/lib/req-parser";
Expand All @@ -97,6 +101,7 @@ interface ReqViewerProps {
nip05Authors?: string[];
nip05PTags?: string[];
needsAccount?: boolean;
needsInterestList?: boolean;
title?: string;
}

Expand Down Expand Up @@ -650,6 +655,7 @@ export default function ReqViewer({
nip05Authors,
nip05PTags,
needsAccount = false,
needsInterestList = false,
title = "nostr-events",
}: ReqViewerProps) {
const { state, addWindow } = useGrimoire();
Expand Down Expand Up @@ -680,13 +686,48 @@ export default function ReqViewer({
[contactListEvent],
);

// Resolve $me and $contacts aliases (memoized to prevent unnecessary object creation)
// Memoize interest list pointer to prevent unnecessary re-subscriptions
const interestListPointer = useMemo(
() =>
needsInterestList && accountPubkey
? { kind: 10015, pubkey: accountPubkey, identifier: "" }
: undefined,
[needsInterestList, accountPubkey],
);

// Fetch interest list (kind 10015) if needed for $hashtags resolution
const interestListEvent = useNostrEvent(interestListPointer);

// Extract hashtags from kind 10015 event (includes hidden/encrypted tags after decryption)
const hashtags = useMemo(
() => (interestListEvent ? getAllTagValues(interestListEvent, "t") : []),
[interestListEvent],
);

// Compute interest list status for UI feedback
const interestListStatus = useMemo(() => {
if (!needsInterestList) return null;
if (!accountPubkey) return null; // Account required error handles this
if (interestListEvent === undefined) return "loading";
if (interestListEvent === null) return "not-found";
if (hashtags.length === 0) return "empty";
return "ok";
}, [needsInterestList, accountPubkey, interestListEvent, hashtags.length]);

// Resolve $me, $contacts, and $hashtags aliases (memoized to prevent unnecessary object creation)
const resolvedFilter = useMemo(
() =>
needsAccount
? resolveFilterAliases(filter, accountPubkey, contacts)
needsAccount || needsInterestList
? resolveFilterAliases(filter, accountPubkey, contacts, { hashtags })
: filter,
[needsAccount, filter, accountPubkey, contacts],
[
needsAccount,
needsInterestList,
filter,
accountPubkey,
contacts,
hashtags,
],
);

// NIP-05 resolution already happened in argParser before window creation
Expand Down Expand Up @@ -1254,24 +1295,55 @@ export default function ReqViewer({
</div>
)}

{/* Interest List Warning Banner */}
{interestListStatus === "not-found" && (
<div className="border-b border-border px-4 py-2 bg-warning/10 flex items-center gap-2">
<Hash className="size-4 text-warning" />
<span className="text-xs text-warning">
No interest list found (kind 10015).{" "}
<code className="bg-muted px-1 py-0.5 rounded">$hashtags</code>{" "}
ignored.
</span>
</div>
)}
{interestListStatus === "empty" && (
<div className="border-b border-border px-4 py-2 bg-warning/10 flex items-center gap-2">
<Hash className="size-4 text-warning" />
<span className="text-xs text-warning">
Interest list has no hashtags.{" "}
<code className="bg-muted px-1 py-0.5 rounded">$hashtags</code>{" "}
ignored.
</span>
</div>
)}

{/* Account Required Error */}
{needsAccount && !accountPubkey && (
{(needsAccount || needsInterestList) && !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.
{needsAccount && (
<>
<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>
</>
)}
{needsAccount && needsInterestList && " or "}
{needsInterestList && (
<code className="bg-muted px-1.5 py-0.5">$hashtags</code>
)}{" "}
alias{needsAccount && needsInterestList ? "es" : ""} and requires
an active account.
</p>
</div>
</div>
)}

{/* Results */}
{(!needsAccount || accountPubkey) && (
{((!needsAccount && !needsInterestList) || accountPubkey) && (
<div className="flex-1 overflow-y-auto relative">
{/* Floating "New Events" Button */}
{isFrozen && newEventCount > 0 && (
Expand Down
1 change: 1 addition & 0 deletions src/components/WindowRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
nip05Authors={window.props.nip05Authors}
nip05PTags={window.props.nip05PTags}
needsAccount={window.props.needsAccount}
needsInterestList={window.props.needsInterestList}
/>
);
break;
Expand Down
63 changes: 61 additions & 2 deletions src/components/nostr/kinds/SpellRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import { Badge } from "@/components/ui/badge";
import { KindBadge } from "@/components/KindBadge";
import { SpellEvent } from "@/types/spell";
import { CopyableJsonViewer } from "@/components/JsonViewer";
import { User, Users } from "lucide-react";
import { User, Users, Hash } from "lucide-react";
import { cn } from "@/lib/utils";
import { UserName } from "../UserName";
import { useGrimoire } from "@/core/state";
import { useProfile } from "@/hooks/useProfile";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { getDisplayName } from "@/lib/nostr-utils";
import { getDisplayName, getAllTagValues } from "@/lib/nostr-utils";

/**
* Visual placeholder for $me
Expand Down Expand Up @@ -109,6 +109,61 @@ export function ContactsPlaceholder({
);
}

/**
* Visual placeholder for $hashtags
*/
export function HashtagsPlaceholder({
size = "sm",
className,
pubkey,
}: {
size?: "sm" | "md" | "lg";
className?: string;
pubkey?: string;
}) {
const { addWindow } = useGrimoire();
const interestList = useNostrEvent(
pubkey
? {
kind: 10015,
pubkey,
identifier: "",
}
: undefined,
);

const hashtags = interestList ? getAllTagValues(interestList, "t") : [];
const count = hashtags.length;
const label = count > 0 ? `${count} interests` : "$hashtags";

const handleClick = (e: React.MouseEvent) => {
if (!pubkey) return;
e.stopPropagation();
addWindow("open", {
pointer: {
kind: 10015,
pubkey,
identifier: "",
},
});
};

return (
<span
className={cn(
"inline-flex items-center gap-1.5 font-bold text-emerald-400 select-none",
pubkey && "cursor-crosshair hover:underline decoration-dotted",
size === "sm" ? "text-xs" : size === "md" ? "text-sm" : "text-lg",
className,
)}
onClick={handleClick}
>
<Hash className={cn(size === "sm" ? "size-3" : "size-4")} />
{label}
</span>
);
}

/**
* Renderer for a list of identifiers (pubkeys or placeholders)
*/
Expand All @@ -130,6 +185,10 @@ function IdentifierList({
return (
<ContactsPlaceholder key={val} size={size} pubkey={activePubkey} />
);
if (val === "$hashtags")
return (
<HashtagsPlaceholder key={val} size={size} pubkey={activePubkey} />
);
return (
<UserName
key={val}
Expand Down
144 changes: 144 additions & 0 deletions src/lib/nostr-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,4 +417,148 @@ describe("resolveFilterAliases", () => {
expect(result["#P"]).toContain(contacts[1]);
});
});

describe("$hashtags alias resolution", () => {
it("should replace $hashtags with hashtag list in #t", () => {
const filter: NostrFilter = { "#t": ["$hashtags"] };
const hashtags = ["nostr", "bitcoin", "lightning"];
const result = resolveFilterAliases(filter, undefined, [], { hashtags });

expect(result["#t"]).toEqual(hashtags);
expect(result["#t"]).not.toContain("$hashtags");
});

it("should remove #t from filter when $hashtags resolves to empty", () => {
const filter: NostrFilter = { "#t": ["$hashtags"] };
const result = resolveFilterAliases(filter, undefined, [], {
hashtags: [],
});

// Empty #t should be removed from filter entirely
expect(result["#t"]).toBeUndefined();
});

it("should preserve other hashtags when $hashtags resolves to empty", () => {
const filter: NostrFilter = { "#t": ["$hashtags", "nostr", "bitcoin"] };
const result = resolveFilterAliases(filter, undefined, [], {
hashtags: [],
});

// Other hashtags should be preserved
expect(result["#t"]).toEqual(["nostr", "bitcoin"]);
});

it("should preserve other hashtags when resolving $hashtags", () => {
const filter: NostrFilter = { "#t": ["$hashtags", "zaps", "dev"] };
const hashtags = ["nostr", "bitcoin"];
const result = resolveFilterAliases(filter, undefined, [], { hashtags });

expect(result["#t"]).toContain("nostr");
expect(result["#t"]).toContain("bitcoin");
expect(result["#t"]).toContain("zaps");
expect(result["#t"]).toContain("dev");
expect(result["#t"]).not.toContain("$hashtags");
});

it("should deduplicate hashtags", () => {
const filter: NostrFilter = { "#t": ["$hashtags", "nostr"] };
const hashtags = ["nostr", "bitcoin", "nostr"]; // nostr appears in both
const result = resolveFilterAliases(filter, undefined, [], { hashtags });

const nostrCount = result["#t"]?.filter((t) => t === "nostr").length;
expect(nostrCount).toBe(1);
});

it("should handle case-insensitive $HASHTAGS alias", () => {
const filter: NostrFilter = { "#t": ["$HASHTAGS"] };
const hashtags = ["nostr", "bitcoin"];
const result = resolveFilterAliases(filter, undefined, [], { hashtags });

expect(result["#t"]).toEqual(hashtags);
expect(result["#t"]).not.toContain("$HASHTAGS");
});

it("should handle mixed case $Hashtags alias", () => {
const filter: NostrFilter = { "#t": ["$Hashtags"] };
const hashtags = ["nostr", "bitcoin"];
const result = resolveFilterAliases(filter, undefined, [], { hashtags });

expect(result["#t"]).toEqual(hashtags);
expect(result["#t"]).not.toContain("$Hashtags");
});
});

describe("combined $me, $contacts, and $hashtags", () => {
it("should resolve all aliases in same filter", () => {
const filter: NostrFilter = {
authors: ["$me", "$contacts"],
"#t": ["$hashtags"],
};
const accountPubkey = "a".repeat(64);
const contacts = ["b".repeat(64), "c".repeat(64)];
const hashtags = ["nostr", "bitcoin"];
const result = resolveFilterAliases(filter, accountPubkey, contacts, {
hashtags,
});

expect(result.authors).toContain(accountPubkey);
expect(result.authors).toContain(contacts[0]);
expect(result.authors).toContain(contacts[1]);
expect(result["#t"]).toEqual(hashtags);
});

it("should work with new options-based signature", () => {
const filter: NostrFilter = {
authors: ["$me"],
"#t": ["$hashtags"],
};
const result = resolveFilterAliases(filter, {
accountPubkey: "a".repeat(64),
contacts: ["b".repeat(64)],
hashtags: ["nostr", "bitcoin"],
});

expect(result.authors).toEqual(["a".repeat(64)]);
expect(result["#t"]).toEqual(["nostr", "bitcoin"]);
});

it("should handle missing hashtags option gracefully", () => {
const filter: NostrFilter = {
authors: ["$me"],
"#t": ["$hashtags"],
};
const accountPubkey = "a".repeat(64);
const result = resolveFilterAliases(filter, accountPubkey, []);

expect(result.authors).toEqual([accountPubkey]);
// Empty #t should be removed from filter entirely
expect(result["#t"]).toBeUndefined();
});
});

describe("options-based signature", () => {
it("should work with options object as second parameter", () => {
const filter: NostrFilter = {
authors: ["$me"],
"#p": ["$contacts"],
"#t": ["$hashtags"],
};
const result = resolveFilterAliases(filter, {
accountPubkey: "a".repeat(64),
contacts: ["b".repeat(64), "c".repeat(64)],
hashtags: ["nostr", "bitcoin"],
});

expect(result.authors).toEqual(["a".repeat(64)]);
expect(result["#p"]).toEqual(["b".repeat(64), "c".repeat(64)]);
expect(result["#t"]).toEqual(["nostr", "bitcoin"]);
});

it("should handle undefined values in options", () => {
const filter: NostrFilter = { authors: ["$me"] };
const result = resolveFilterAliases(filter, {});

expect(result.authors).toEqual([]);
});
});
});
Loading
Loading