diff --git a/src/config/favorite-lists.ts b/src/config/favorite-lists.ts index 9028907..2a382a8 100644 --- a/src/config/favorite-lists.ts +++ b/src/config/favorite-lists.ts @@ -1,4 +1,6 @@ import { SPELL_KIND } from "@/constants/kinds"; +import type { TagStrategy } from "@/lib/favorite-tag-strategies"; +import { groupTagStrategy } from "@/lib/favorite-tag-strategies"; export interface FavoriteListConfig { /** The replaceable list kind that stores favorites (e.g., 10777) */ @@ -7,12 +9,15 @@ export interface FavoriteListConfig { elementKind: number; /** Human-readable label for UI */ label: string; + /** Override the default e/a tag strategy (derived from isAddressableKind) */ + tagStrategy?: TagStrategy; } /** * Maps event kind → favorite list configuration. * - * Tag type ("e" vs "a") is derived at runtime from isAddressableKind(elementKind). + * Tag type ("e" vs "a") is derived at runtime from isAddressableKind(elementKind) + * unless a custom tagStrategy is provided. * To add a new favoritable kind, just add an entry here. */ export const FAVORITE_LISTS: Record = { @@ -31,6 +36,12 @@ export const FAVORITE_LISTS: Record = { elementKind: 30030, label: "Emoji Sets", }, + 39000: { + listKind: 10009, + elementKind: 39000, + label: "Favorite Groups", + tagStrategy: groupTagStrategy, + }, }; /** diff --git a/src/hooks/useFavoriteList.ts b/src/hooks/useFavoriteList.ts index f9ba21d..0b30e2b 100644 --- a/src/hooks/useFavoriteList.ts +++ b/src/hooks/useFavoriteList.ts @@ -3,9 +3,7 @@ import { use$ } from "applesauce-react/hooks"; import { getEventPointerFromETag, getAddressPointerFromATag, - getTagValue, } from "applesauce-core/helpers"; -import { getSeenRelays } from "applesauce-core/helpers/relays"; import { EventFactory } from "applesauce-core/event-factory"; import eventStore from "@/services/event-store"; import accountManager from "@/services/accounts"; @@ -13,18 +11,16 @@ import { settingsManager } from "@/services/settings"; import { publishEvent } from "@/services/hub"; import { useAccount } from "@/hooks/useAccount"; import { isAddressableKind } from "@/lib/nostr-kinds"; +import { eTagStrategy, aTagStrategy } from "@/lib/favorite-tag-strategies"; import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; +import type { TagStrategy } from "@/lib/favorite-tag-strategies"; import type { FavoriteListConfig } from "@/config/favorite-lists"; import type { NostrEvent } from "@/types/nostr"; import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; -/** Compute the identity key for an event based on tag type */ -function getItemKey(event: NostrEvent, tagType: "e" | "a"): string { - if (tagType === "a") { - const dTag = getTagValue(event, "d") || ""; - return `${event.kind}:${event.pubkey}:${dTag}`; - } - return event.id; +export function resolveStrategy(config: FavoriteListConfig): TagStrategy { + if (config.tagStrategy) return config.tagStrategy; + return isAddressableKind(config.elementKind) ? aTagStrategy : eTagStrategy; } /** Extract pointers from tags of a given type */ @@ -59,31 +55,19 @@ export function getListPointers( return pointers; } -/** Build a tag for adding an item to a favorite list */ -function buildTag(event: NostrEvent, tagType: "e" | "a"): string[] { - const seenRelays = getSeenRelays(event); - const relayHint = seenRelays ? Array.from(seenRelays)[0] || "" : ""; - - if (tagType === "a") { - const dTag = getTagValue(event, "d") || ""; - const coordinate = `${event.kind}:${event.pubkey}:${dTag}`; - return relayHint ? ["a", coordinate, relayHint] : ["a", coordinate]; - } - - return relayHint ? ["e", event.id, relayHint] : ["e", event.id]; -} - /** * Generic hook to read and manage a NIP-51-style favorite list. * - * Tag type ("e" vs "a") is derived from the element kind using isAddressableKind(). + * Tag format is determined by the config's tagStrategy (defaults to "e"/"a" + * based on isAddressableKind). Pass a custom TagStrategy for non-standard + * tag formats like NIP-29 "group" tags. */ export function useFavoriteList(config: FavoriteListConfig) { const { pubkey, canSign } = useAccount(); const [isUpdating, setIsUpdating] = useState(false); const isUpdatingRef = useRef(false); - const tagType = isAddressableKind(config.elementKind) ? "a" : "e"; + const strategy = resolveStrategy(config); // Subscribe to the user's replaceable list event const event = use$( @@ -92,30 +76,33 @@ export function useFavoriteList(config: FavoriteListConfig) { [pubkey, config.listKind], ); - // Extract pointers from matching tags - const items = useMemo( - () => (event ? getListPointers(event, tagType) : []), - [event, tagType], - ); + // Extract pointers from matching tags (only meaningful for e/a strategies) + const items = useMemo(() => { + if (!event) return []; + if (strategy.tagName === "e") return getListPointers(event, "e"); + if (strategy.tagName === "a") return getListPointers(event, "a"); + return []; + }, [event, strategy.tagName]); // Quick lookup set of item identity keys const itemIds = useMemo(() => { if (!event) return new Set(); const ids = new Set(); for (const tag of event.tags) { - if (tag[0] === tagType && tag[1]) { - ids.add(tag[1]); + if (tag[0] === strategy.tagName && tag[1]) { + const key = strategy.keyFromTag(tag); + if (key) ids.add(key); } } return ids; - }, [event, tagType]); + }, [event, strategy]); const isFavorite = useCallback( (targetEvent: NostrEvent) => { - const key = getItemKey(targetEvent, tagType); - return itemIds.has(key); + const key = strategy.getItemKey(targetEvent); + return key !== "" && itemIds.has(key); }, - [tagType, itemIds], + [strategy, itemIds], ); const toggleFavorite = useCallback( @@ -131,18 +118,18 @@ export function useFavoriteList(config: FavoriteListConfig) { const currentTags = event ? event.tags.map((t) => [...t]) : []; const currentContent = event?.content ?? ""; - const itemKey = getItemKey(targetEvent, tagType); - const alreadyFavorited = currentTags.some( - (t) => t[0] === tagType && t[1] === itemKey, + const itemKey = strategy.getItemKey(targetEvent); + if (!itemKey) return; + + const alreadyFavorited = currentTags.some((t) => + strategy.matchesKey(t, itemKey), ); let newTags: string[][]; if (alreadyFavorited) { - newTags = currentTags.filter( - (t) => !(t[0] === tagType && t[1] === itemKey), - ); + newTags = currentTags.filter((t) => !strategy.matchesKey(t, itemKey)); } else { - newTags = [...currentTags, buildTag(targetEvent, tagType)]; + newTags = [...currentTags, strategy.buildTag(targetEvent)]; } if (settingsManager.getSetting("post", "includeClientTag")) { @@ -168,7 +155,7 @@ export function useFavoriteList(config: FavoriteListConfig) { setIsUpdating(false); } }, - [canSign, config, event, tagType], + [canSign, config, event, strategy], ); return { diff --git a/src/lib/favorite-tag-strategies.test.ts b/src/lib/favorite-tag-strategies.test.ts new file mode 100644 index 0000000..b9a9a4d --- /dev/null +++ b/src/lib/favorite-tag-strategies.test.ts @@ -0,0 +1,379 @@ +import { describe, it, expect } from "vitest"; +import { SeenRelaysSymbol } from "applesauce-core/helpers/relays"; +import { + eTagStrategy, + aTagStrategy, + groupTagStrategy, +} from "./favorite-tag-strategies"; +import type { NostrEvent } from "@/types/nostr"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEvent(overrides: Partial = {}): NostrEvent { + return { + id: "abc123def456", + pubkey: "pub123", + created_at: 1700000000, + kind: 1, + tags: [], + content: "", + sig: "sig", + ...overrides, + }; +} + +function withSeenRelays(event: NostrEvent, relays: string[]): NostrEvent { + (event as any)[SeenRelaysSymbol] = new Set(relays); + return event; +} + +// =========================================================================== +// eTagStrategy +// =========================================================================== + +describe("eTagStrategy", () => { + describe("getItemKey", () => { + it("returns the event id", () => { + const event = makeEvent({ id: "deadbeef" }); + expect(eTagStrategy.getItemKey(event)).toBe("deadbeef"); + }); + }); + + describe("buildTag", () => { + it("returns [e, id] when no seen relays", () => { + const event = makeEvent({ id: "deadbeef" }); + expect(eTagStrategy.buildTag(event)).toEqual(["e", "deadbeef"]); + }); + + it("includes relay hint from seen relays", () => { + const event = withSeenRelays(makeEvent({ id: "deadbeef" }), [ + "wss://relay.example.com/", + ]); + expect(eTagStrategy.buildTag(event)).toEqual([ + "e", + "deadbeef", + "wss://relay.example.com/", + ]); + }); + }); + + describe("matchesKey", () => { + it("matches when tag[0]=e and tag[1]=key", () => { + expect(eTagStrategy.matchesKey(["e", "abc"], "abc")).toBe(true); + }); + + it("does not match different key", () => { + expect(eTagStrategy.matchesKey(["e", "abc"], "xyz")).toBe(false); + }); + + it("does not match different tag type", () => { + expect(eTagStrategy.matchesKey(["a", "abc"], "abc")).toBe(false); + }); + }); + + describe("keyFromTag", () => { + it("returns tag[1] for e tags", () => { + expect(eTagStrategy.keyFromTag(["e", "abc", "relay"])).toBe("abc"); + }); + + it("returns undefined for non-e tags", () => { + expect(eTagStrategy.keyFromTag(["a", "abc"])).toBeUndefined(); + }); + + it("returns undefined for empty e tag", () => { + expect(eTagStrategy.keyFromTag(["e"])).toBeUndefined(); + }); + }); +}); + +// =========================================================================== +// aTagStrategy +// =========================================================================== + +describe("aTagStrategy", () => { + describe("getItemKey", () => { + it("returns kind:pubkey:d-tag coordinate", () => { + const event = makeEvent({ + kind: 30023, + pubkey: "author1", + tags: [["d", "my-article"]], + }); + expect(aTagStrategy.getItemKey(event)).toBe("30023:author1:my-article"); + }); + + it("handles missing d-tag gracefully", () => { + const event = makeEvent({ kind: 30023, pubkey: "author1" }); + expect(aTagStrategy.getItemKey(event)).toBe("30023:author1:"); + }); + }); + + describe("buildTag", () => { + it("returns [a, coordinate] when no seen relays", () => { + const event = makeEvent({ + kind: 30617, + pubkey: "pub1", + tags: [["d", "repo-id"]], + }); + expect(aTagStrategy.buildTag(event)).toEqual(["a", "30617:pub1:repo-id"]); + }); + + it("includes relay hint from seen relays", () => { + const event = withSeenRelays( + makeEvent({ + kind: 30617, + pubkey: "pub1", + tags: [["d", "repo-id"]], + }), + ["wss://relay.example.com/"], + ); + expect(aTagStrategy.buildTag(event)).toEqual([ + "a", + "30617:pub1:repo-id", + "wss://relay.example.com/", + ]); + }); + }); + + describe("matchesKey", () => { + it("matches when tag[0]=a and tag[1]=key", () => { + expect( + aTagStrategy.matchesKey( + ["a", "30617:pub1:repo-id"], + "30617:pub1:repo-id", + ), + ).toBe(true); + }); + + it("does not match different coordinate", () => { + expect( + aTagStrategy.matchesKey( + ["a", "30617:pub1:repo-id"], + "30617:pub1:other", + ), + ).toBe(false); + }); + + it("does not match different tag type", () => { + expect( + aTagStrategy.matchesKey( + ["e", "30617:pub1:repo-id"], + "30617:pub1:repo-id", + ), + ).toBe(false); + }); + }); + + describe("keyFromTag", () => { + it("returns tag[1] for a tags", () => { + expect(aTagStrategy.keyFromTag(["a", "30617:pub1:repo"])).toBe( + "30617:pub1:repo", + ); + }); + + it("returns undefined for non-a tags", () => { + expect(aTagStrategy.keyFromTag(["e", "abc"])).toBeUndefined(); + }); + }); +}); + +// =========================================================================== +// groupTagStrategy +// =========================================================================== + +describe("groupTagStrategy", () => { + describe("getItemKey", () => { + it("returns normalizedRelayUrl'groupId", () => { + const event = withSeenRelays( + makeEvent({ + kind: 39000, + tags: [["d", "bitcoin-dev"]], + }), + ["wss://groups.nostr.com/"], + ); + expect(groupTagStrategy.getItemKey(event)).toBe( + "wss://groups.nostr.com/'bitcoin-dev", + ); + }); + + it("normalizes relay URL without protocol", () => { + const event = withSeenRelays( + makeEvent({ + kind: 39000, + tags: [["d", "test-group"]], + }), + ["groups.nostr.com"], + ); + expect(groupTagStrategy.getItemKey(event)).toBe( + "wss://groups.nostr.com/'test-group", + ); + }); + + it("returns empty string when no seen relays", () => { + const event = makeEvent({ + kind: 39000, + tags: [["d", "bitcoin-dev"]], + }); + expect(groupTagStrategy.getItemKey(event)).toBe(""); + }); + + it("handles missing d-tag", () => { + const event = withSeenRelays(makeEvent({ kind: 39000 }), [ + "wss://groups.nostr.com/", + ]); + expect(groupTagStrategy.getItemKey(event)).toBe( + "wss://groups.nostr.com/'", + ); + }); + }); + + describe("buildTag", () => { + it("returns [group, groupId, normalizedRelay]", () => { + const event = withSeenRelays( + makeEvent({ + kind: 39000, + tags: [["d", "bitcoin-dev"]], + }), + ["wss://groups.nostr.com/"], + ); + expect(groupTagStrategy.buildTag(event)).toEqual([ + "group", + "bitcoin-dev", + "wss://groups.nostr.com/", + ]); + }); + + it("normalizes relay URL in built tag", () => { + const event = withSeenRelays( + makeEvent({ + kind: 39000, + tags: [["d", "test-group"]], + }), + ["Groups.Nostr.COM"], + ); + const tag = groupTagStrategy.buildTag(event); + expect(tag[2]).toBe("wss://groups.nostr.com/"); + }); + + it("returns tag without relay when no seen relays", () => { + const event = makeEvent({ + kind: 39000, + tags: [["d", "bitcoin-dev"]], + }); + const tag = groupTagStrategy.buildTag(event); + expect(tag).toEqual(["group", "bitcoin-dev"]); + }); + }); + + describe("matchesKey", () => { + it("matches with normalized relay URL", () => { + expect( + groupTagStrategy.matchesKey( + ["group", "bitcoin-dev", "wss://groups.nostr.com/"], + "wss://groups.nostr.com/'bitcoin-dev", + ), + ).toBe(true); + }); + + it("normalizes relay URL in tag for comparison", () => { + expect( + groupTagStrategy.matchesKey( + ["group", "bitcoin-dev", "groups.nostr.com"], + "wss://groups.nostr.com/'bitcoin-dev", + ), + ).toBe(true); + }); + + it("handles wss:// vs no-protocol mismatch", () => { + expect( + groupTagStrategy.matchesKey( + ["group", "test", "wss://relay.example.com/"], + "wss://relay.example.com/'test", + ), + ).toBe(true); + }); + + it("handles ws:// protocol", () => { + // ws:// stays as ws:// after normalization + expect( + groupTagStrategy.matchesKey( + ["group", "test", "ws://relay.example.com/"], + "ws://relay.example.com/'test", + ), + ).toBe(true); + }); + + it("does not match different group id", () => { + expect( + groupTagStrategy.matchesKey( + ["group", "bitcoin-dev", "wss://groups.nostr.com/"], + "wss://groups.nostr.com/'other-group", + ), + ).toBe(false); + }); + + it("does not match different relay", () => { + expect( + groupTagStrategy.matchesKey( + ["group", "bitcoin-dev", "wss://groups.nostr.com/"], + "wss://other-relay.com/'bitcoin-dev", + ), + ).toBe(false); + }); + + it("does not match non-group tags", () => { + expect( + groupTagStrategy.matchesKey( + ["e", "bitcoin-dev", "wss://groups.nostr.com/"], + "wss://groups.nostr.com/'bitcoin-dev", + ), + ).toBe(false); + }); + + it("returns false when tag has no relay", () => { + expect( + groupTagStrategy.matchesKey( + ["group", "bitcoin-dev"], + "wss://groups.nostr.com/'bitcoin-dev", + ), + ).toBe(false); + }); + }); + + describe("keyFromTag", () => { + it("returns normalizedRelayUrl'groupId", () => { + expect( + groupTagStrategy.keyFromTag([ + "group", + "bitcoin-dev", + "wss://groups.nostr.com/", + ]), + ).toBe("wss://groups.nostr.com/'bitcoin-dev"); + }); + + it("normalizes relay URL in tag", () => { + expect( + groupTagStrategy.keyFromTag([ + "group", + "bitcoin-dev", + "groups.nostr.com", + ]), + ).toBe("wss://groups.nostr.com/'bitcoin-dev"); + }); + + it("returns undefined for non-group tags", () => { + expect(groupTagStrategy.keyFromTag(["e", "abc"])).toBeUndefined(); + }); + + it("returns undefined for group tag without relay", () => { + expect( + groupTagStrategy.keyFromTag(["group", "bitcoin-dev"]), + ).toBeUndefined(); + }); + + it("returns undefined for group tag without groupId", () => { + expect(groupTagStrategy.keyFromTag(["group"])).toBeUndefined(); + }); + }); +}); diff --git a/src/lib/favorite-tag-strategies.ts b/src/lib/favorite-tag-strategies.ts new file mode 100644 index 0000000..6e9a44e --- /dev/null +++ b/src/lib/favorite-tag-strategies.ts @@ -0,0 +1,109 @@ +import { getTagValue } from "applesauce-core/helpers"; +import { getSeenRelays } from "applesauce-core/helpers/relays"; +import { normalizeRelayURL } from "@/lib/relay-url"; +import type { NostrEvent } from "@/types/nostr"; + +/** Abstracts tag format so useFavoriteList works with e, a, group, or future tag types. */ +export interface TagStrategy { + /** The tag name used in the list event (e.g., "e", "a", "group") */ + tagName: string; + /** Compute an identity key for the given event (used in the membership set) */ + getItemKey(event: NostrEvent): string; + /** Build a complete tag array for adding an event to the list */ + buildTag(event: NostrEvent): string[]; + /** Check if a stored tag matches a given identity key */ + matchesKey(tag: string[], key: string): boolean; + /** Extract the identity key directly from a stored tag (for building itemIds set) */ + keyFromTag(tag: string[]): string | undefined; +} + +function firstSeenRelay(event: NostrEvent): string { + const relays = getSeenRelays(event); + return relays ? Array.from(relays)[0] || "" : ""; +} + +/** Strategy for "e" tags — regular (non-addressable) events. */ +export const eTagStrategy: TagStrategy = { + tagName: "e", + getItemKey(event) { + return event.id; + }, + buildTag(event) { + const relay = firstSeenRelay(event); + return relay ? ["e", event.id, relay] : ["e", event.id]; + }, + matchesKey(tag, key) { + return eTagStrategy.keyFromTag(tag) === key; + }, + keyFromTag(tag) { + return tag[0] === "e" && tag[1] ? tag[1] : undefined; + }, +}; + +/** Strategy for "a" tags — addressable (parameterized replaceable) events. */ +export const aTagStrategy: TagStrategy = { + tagName: "a", + getItemKey(event) { + const dTag = getTagValue(event, "d") || ""; + return `${event.kind}:${event.pubkey}:${dTag}`; + }, + buildTag(event) { + const dTag = getTagValue(event, "d") || ""; + const coordinate = `${event.kind}:${event.pubkey}:${dTag}`; + const relay = firstSeenRelay(event); + return relay ? ["a", coordinate, relay] : ["a", coordinate]; + }, + matchesKey(tag, key) { + return aTagStrategy.keyFromTag(tag) === key; + }, + keyFromTag(tag) { + return tag[0] === "a" && tag[1] ? tag[1] : undefined; + }, +}; + +/** + * Strategy for "group" tags — NIP-29 relay-based groups (kind 10009 lists). + * + * Identity is `normalizedRelayUrl'groupId` to match the pattern used by + * useNip29GroupList and the NIP-29 adapter. + */ +export const groupTagStrategy: TagStrategy = { + tagName: "group", + getItemKey(event) { + const groupId = getTagValue(event, "d") || ""; + const relay = firstSeenRelay(event); + if (!relay) return ""; + try { + return `${normalizeRelayURL(relay)}'${groupId}`; + } catch { + return ""; + } + }, + buildTag(event) { + const groupId = getTagValue(event, "d") || ""; + const relay = firstSeenRelay(event); + if (!relay) { + console.warn( + "[useFavoriteList] Cannot build group tag: no seen relay for event", + event.id, + ); + return ["group", groupId]; + } + try { + return ["group", groupId, normalizeRelayURL(relay)]; + } catch { + return ["group", groupId, relay]; + } + }, + matchesKey(tag, key) { + return groupTagStrategy.keyFromTag(tag) === key; + }, + keyFromTag(tag) { + if (tag[0] !== "group" || !tag[1] || !tag[2]) return undefined; + try { + return `${normalizeRelayURL(tag[2])}'${tag[1]}`; + } catch { + return undefined; + } + }, +};