feat: bookmark nip-29 groups and always fetch group list

This commit is contained in:
Alejandro Gómez
2026-04-03 10:48:56 +02:00
parent 5eb67631ec
commit be3b97be6d
4 changed files with 531 additions and 45 deletions

View File

@@ -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<number, FavoriteListConfig> = {
@@ -31,6 +36,12 @@ export const FAVORITE_LISTS: Record<number, FavoriteListConfig> = {
elementKind: 30030,
label: "Emoji Sets",
},
39000: {
listKind: 10009,
elementKind: 39000,
label: "Favorite Groups",
tagStrategy: groupTagStrategy,
},
};
/**

View File

@@ -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<string>();
const ids = new Set<string>();
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 {

View File

@@ -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> = {}): 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();
});
});
});

View File

@@ -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;
}
},
};