@nostr-dev-kit/ndk
Version:
NDK - Nostr Development Kit. Includes AI Guardrails to catch common mistakes during development.
416 lines (362 loc) • 12.7 kB
text/typescript
import type { NDK } from "../../../ndk/index.js";
import { NDKRelay } from "../../../relay/index.js";
import type { NDKFilter } from "../../../subscription/index.js";
import { NDKUser } from "../../../user/index.js";
import type { NDKEventId, NDKTag, NostrEvent } from "../../index.js";
import { NDKEvent } from "../../index.js";
import { NDKKind } from "../index.js";
export type NDKListItem = NDKRelay | NDKUser | NDKEvent;
/**
* Represents any NIP-51 list kind.
*
* This class provides some helper methods to manage the list, particularly
* a CRUD interface to list items.
*
* List items can be encrypted or not. Encrypted items are JSON-encoded and
* self-signed by the user's key.
*
* @example Adding an event to the list.
* const event1 = new NDKEvent(...);
* const list = new NDKList();
* list.addItem(event1);
*
* @example Adding an encrypted `p` tag to the list with a "person" mark.
* const secretFollow = new NDKUser(...);
* list.addItem(secretFollow, 'person', true);
*
* @emits change
* @group Kind Wrapper
*/
export class NDKList extends NDKEvent {
public _encryptedTags: NDKTag[] | undefined;
static kind = NDKKind.CategorizedBookmarkList;
static kinds: NDKKind[] = [
NDKKind.CategorizedBookmarkList,
NDKKind.CommunityList,
NDKKind.DirectMessageReceiveRelayList,
NDKKind.EmojiList,
NDKKind.InterestList,
NDKKind.PinList,
NDKKind.RelayList,
NDKKind.SearchRelayList,
NDKKind.BlockRelayList,
NDKKind.BookmarkList,
NDKKind.RelayFeedList,
];
/**
* Stores the number of bytes the content was before decryption
* to expire the cache when the content changes.
*/
private encryptedTagsLength: number | undefined;
constructor(ndk?: NDK, rawEvent?: NostrEvent | NDKEvent) {
super(ndk, rawEvent);
this.kind ??= NDKKind.CategorizedBookmarkList;
}
/**
* Wrap a NDKEvent into a NDKList
*/
static from(ndkEvent: NDKEvent): NDKList {
return new NDKList(ndkEvent.ndk, ndkEvent);
}
/**
* Returns the title of the list. Falls back on fetching the name tag value.
*/
get title(): string | undefined {
const titleTag = this.tagValue("title") || this.tagValue("name");
if (titleTag) return titleTag;
if (this.kind === NDKKind.Contacts) {
return "Contacts";
}
if (this.kind === NDKKind.MuteList) {
return "Mute";
}
if (this.kind === NDKKind.PinList) {
return "Pinned Notes";
}
if (this.kind === NDKKind.RelayList) {
return "Relay Metadata";
}
if (this.kind === NDKKind.BookmarkList) {
return "Bookmarks";
}
if (this.kind === NDKKind.CommunityList) {
return "Communities";
}
if (this.kind === NDKKind.PublicChatList) {
return "Public Chats";
}
if (this.kind === NDKKind.BlockRelayList) {
return "Blocked Relays";
}
if (this.kind === NDKKind.SearchRelayList) {
return "Search Relays";
}
if (this.kind === NDKKind.DirectMessageReceiveRelayList) {
return "Direct Message Receive Relays";
}
if (this.kind === NDKKind.RelayFeedList) {
return "Relay Feeds";
}
if (this.kind === NDKKind.InterestList) {
return "Interests";
}
if (this.kind === NDKKind.EmojiList) {
return "Emojis";
}
return this.tagValue("d");
}
/**
* Sets the title of the list.
*/
set title(title: string | undefined) {
this.removeTag(["title", "name"]);
if (title) this.tags.push(["title", title]);
}
/**
* Returns the name of the list.
* @deprecated Please use "title" instead.
*/
get name(): string | undefined {
return this.title;
}
/**
* Sets the name of the list.
* @deprecated Please use "title" instead. This method will use the `title` tag instead.
*/
set name(name: string | undefined) {
this.title = name;
}
/**
* Returns the description of the list.
*/
get description(): string | undefined {
return this.tagValue("description");
}
/**
* Sets the description of the list.
*/
set description(name: string | undefined) {
this.removeTag("description");
if (name) this.tags.push(["description", name]);
}
/**
* Returns the image of the list.
*/
get image(): string | undefined {
return this.tagValue("image");
}
/**
* Sets the image of the list.
*/
set image(name: string | undefined) {
this.removeTag("image");
if (name) this.tags.push(["image", name]);
}
private isEncryptedTagsCacheValid(): boolean {
return !!(this._encryptedTags && this.encryptedTagsLength === this.content.length);
}
/**
* Returns the decrypted content of the list.
*/
async encryptedTags(useCache = true): Promise<NDKTag[]> {
if (useCache && this.isEncryptedTagsCacheValid()) return this._encryptedTags!;
if (!this.ndk) throw new Error("NDK instance not set");
if (!this.ndk.signer) throw new Error("NDK signer not set");
const user = await this.ndk.signer.user();
try {
if (this.content.length > 0) {
try {
const decryptedContent = await this.ndk.signer.decrypt(user, this.content);
const a = JSON.parse(decryptedContent);
if (a?.[0]) {
this.encryptedTagsLength = this.content.length;
return (this._encryptedTags = a);
}
this.encryptedTagsLength = this.content.length;
return (this._encryptedTags = []);
} catch (_e) {}
}
} catch (_e) {
// console.trace(e);
// throw e;
}
return [];
}
/**
* This method can be overriden to validate that a tag is valid for this list.
*
* (i.e. the NDKPersonList can validate that items are NDKUser instances)
*/
public validateTag(_tagValue: string): boolean | string {
return true;
}
getItems(type: string): NDKTag[] {
return this.tags.filter((tag) => tag[0] === type);
}
/**
* Returns the unecrypted items in this list.
*/
get items(): NDKTag[] {
return this.tags.filter((t) => {
return ![
"d",
"L",
"l",
"title",
"name",
"description",
"published_at",
"summary",
"image",
"thumb",
"alt",
"expiration",
"subject",
"client",
].includes(t[0]);
});
}
/**
* Adds a new item to the list.
* @param relay Relay to add
* @param mark Optional mark to add to the item
* @param encrypted Whether to encrypt the item
* @param position Where to add the item in the list (top or bottom)
*/
async addItem(
item: NDKListItem | NDKTag,
mark: string | undefined = undefined,
encrypted = false,
position: "top" | "bottom" = "bottom",
): Promise<void> {
if (!this.ndk) throw new Error("NDK instance not set");
if (!this.ndk.signer) throw new Error("NDK signer not set");
let tags: NDKTag[];
if (item instanceof NDKEvent) {
tags = [item.tagReference(mark)];
} else if (item instanceof NDKUser) {
tags = item.referenceTags();
} else if (item instanceof NDKRelay) {
tags = item.referenceTags();
} else if (Array.isArray(item)) {
// NDKTag
tags = [item];
} else {
throw new Error("Invalid object type");
}
if (mark) tags[0].push(mark);
if (encrypted) {
const user = await this.ndk.signer.user();
const currentList = await this.encryptedTags();
if (position === "top") currentList.unshift(...tags);
else currentList.push(...tags);
this._encryptedTags = currentList;
this.encryptedTagsLength = this.content.length;
this.content = JSON.stringify(currentList);
await this.encrypt(user);
} else {
if (position === "top") this.tags.unshift(...tags);
else this.tags.push(...tags);
}
this.created_at = Math.floor(Date.now() / 1000);
this.emit("change");
}
/**
* Removes an item from the list from both the encrypted and unencrypted lists.
* @param value value of item to remove from the list
* @param publish whether to publish the change
* @returns
*/
async removeItemByValue(value: string, publish = true): Promise<Set<NDKRelay> | undefined> {
if (!this.ndk) throw new Error("NDK instance not set");
if (!this.ndk.signer) throw new Error("NDK signer not set");
// check in unecrypted tags
const index = this.tags.findIndex((tag) => tag[1] === value);
if (index >= 0) {
this.tags.splice(index, 1);
}
// check in encrypted tags
const user = await this.ndk.signer.user();
const encryptedTags = await this.encryptedTags();
const encryptedIndex = encryptedTags.findIndex((tag) => tag[1] === value);
if (encryptedIndex >= 0) {
encryptedTags.splice(encryptedIndex, 1);
this._encryptedTags = encryptedTags;
this.encryptedTagsLength = this.content.length;
this.content = JSON.stringify(encryptedTags);
await this.encrypt(user);
}
if (publish) {
return this.publishReplaceable();
}
this.created_at = Math.floor(Date.now() / 1000);
this.emit("change");
}
/**
* Removes an item from the list.
*
* @param index The index of the item to remove.
* @param encrypted Whether to remove from the encrypted list or not.
*/
async removeItem(index: number, encrypted: boolean): Promise<NDKList> {
if (!this.ndk) throw new Error("NDK instance not set");
if (!this.ndk.signer) throw new Error("NDK signer not set");
if (encrypted) {
const user = await this.ndk.signer.user();
const currentList = await this.encryptedTags();
currentList.splice(index, 1);
this._encryptedTags = currentList;
this.encryptedTagsLength = this.content.length;
this.content = JSON.stringify(currentList);
await this.encrypt(user);
} else {
this.tags.splice(index, 1);
}
this.created_at = Math.floor(Date.now() / 1000);
this.emit("change");
return this;
}
public has(item: string) {
return this.items.some((tag) => tag[1] === item);
}
/**
* Creates a filter that will result in fetching
* the items of this list
* @example
* const list = new NDKList(...);
* const filters = list.filterForItems();
* const events = await ndk.fetchEvents(filters);
*/
filterForItems(): NDKFilter[] {
const ids = new Set<NDKEventId>();
const nip33Queries = new Map<string, string[]>();
const filters: NDKFilter[] = [];
for (const tag of this.items) {
if (tag[0] === "e" && tag[1]) {
ids.add(tag[1]);
} else if (tag[0] === "a" && tag[1]) {
const [kind, pubkey, dTag] = tag[1].split(":");
if (!kind || !pubkey) continue;
const key = `${kind}:${pubkey}`;
const item = nip33Queries.get(key) || [];
item.push(dTag || "");
nip33Queries.set(key, item);
}
}
if (ids.size > 0) {
filters.push({ ids: Array.from(ids) });
}
if (nip33Queries.size > 0) {
for (const [key, values] of nip33Queries.entries()) {
const [kind, pubkey] = key.split(":");
filters.push({
kinds: [Number.parseInt(kind)],
authors: [pubkey],
"#d": values,
});
}
}
return filters;
}
}
export default NDKList;