mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
feat: bookmark nip-29 groups and always fetch group list
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
379
src/lib/favorite-tag-strategies.test.ts
Normal file
379
src/lib/favorite-tag-strategies.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
109
src/lib/favorite-tag-strategies.ts
Normal file
109
src/lib/favorite-tag-strategies.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user