Skip to content

Commit c7df7f4

Browse files
committed
feat: implement NDKFollowPack class and related tests
1 parent 72e6cfc commit c7df7f4

File tree

4 files changed

+291
-0
lines changed

4 files changed

+291
-0
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, it, expect } from "vitest";
2+
import { NDKFollowPack } from "./follow-pack";
3+
import { NDKKind } from "./index";
4+
import type { NDKImetaTag } from "../../utils/imeta";
5+
import { mapImetaTag } from "../../utils/imeta";
6+
7+
// Helper imeta tag object
8+
const imetaObj: NDKImetaTag = {
9+
url: "https://example.com/image.png",
10+
alt: "Example image",
11+
dim: "100x100"
12+
};
13+
14+
describe("NDKFollowPack", () => {
15+
describe("Kind Handling", () => {
16+
it("should default to NDKKind.FollowPack (39089)", () => {
17+
const fp = new NDKFollowPack();
18+
expect(fp.kind).toBe(NDKKind.FollowPack);
19+
});
20+
21+
it("should accept NDKKind.FollowPack and NDKKind.MediaFollowPack", () => {
22+
const fp = new NDKFollowPack();
23+
fp.kind = NDKKind.FollowPack;
24+
expect(NDKFollowPack.kinds).toContain(fp.kind);
25+
26+
fp.kind = NDKKind.MediaFollowPack;
27+
expect(NDKFollowPack.kinds).toContain(fp.kind);
28+
});
29+
});
30+
31+
describe("Getters/Setters", () => {
32+
it("should get/set title and manipulate the title tag", () => {
33+
const fp = new NDKFollowPack();
34+
expect(fp.title).toBeUndefined();
35+
36+
fp.title = "Test Title";
37+
expect(fp.title).toBe("Test Title");
38+
expect(fp.tags.find(t => t[0] === "title")).toEqual(["title", "Test Title"]);
39+
40+
fp.title = undefined;
41+
expect(fp.title).toBeUndefined();
42+
expect(fp.tags.find(t => t[0] === "title")).toBeUndefined();
43+
});
44+
45+
it("should get/set identifier (d tag)", () => {
46+
const fp = new NDKFollowPack();
47+
// No explicit getter/setter, use setTag/tagValue
48+
expect(fp.tagValue("d")).toBeUndefined();
49+
50+
fp["setTag"]("d", "identifier-123");
51+
expect(fp.tagValue("d")).toBe("identifier-123");
52+
53+
fp["setTag"]("d", undefined);
54+
expect(fp.tagValue("d")).toBeUndefined();
55+
});
56+
57+
describe("Image", () => {
58+
it("should get/set image with string URL", () => {
59+
const fp = new NDKFollowPack();
60+
fp.image = "https://example.com/image.png";
61+
expect(fp.image).toBe("https://example.com/image.png");
62+
expect(fp.tags.find(t => t[0] === "image")).toEqual(["image", "https://example.com/image.png"]);
63+
});
64+
65+
it("should get/set image with NDKImetaTag object", () => {
66+
const fp = new NDKFollowPack();
67+
fp.image = imetaObj;
68+
// Should set both imeta and image tags
69+
const imetaTag = fp.tags.find(t => t[0] === "imeta");
70+
expect(imetaTag).toBeDefined();
71+
expect(mapImetaTag(imetaTag! as any).url).toBe(imetaObj.url);
72+
expect(fp.tags.find(t => t[0] === "image")).toEqual(["image", imetaObj.url]);
73+
});
74+
75+
it("should prefer imeta over image when both are present", () => {
76+
const fp = new NDKFollowPack();
77+
fp.image = "https://fallback.com/image.png";
78+
fp.image = imetaObj; // This sets both imeta and image tags
79+
expect(fp.image).toBe(imetaObj.url);
80+
});
81+
82+
it("should remove both imeta and image tags when image is set to undefined", () => {
83+
const fp = new NDKFollowPack();
84+
fp.image = imetaObj;
85+
expect(fp.tags.find(t => t[0] === "imeta")).toBeDefined();
86+
expect(fp.tags.find(t => t[0] === "image")).toBeDefined();
87+
88+
fp.image = undefined;
89+
expect(fp.tags.find(t => t[0] === "imeta")).toBeUndefined();
90+
expect(fp.tags.find(t => t[0] === "image")).toBeUndefined();
91+
});
92+
});
93+
94+
it("should get/set pubkeys and manipulate p tags", () => {
95+
const fp = new NDKFollowPack();
96+
expect(fp.pubkeys).toEqual([]);
97+
98+
fp.pubkeys = ["pk1", "pk2"];
99+
expect(fp.pubkeys).toEqual(["pk1", "pk2"]);
100+
expect(fp.tags.filter(t => t[0] === "p").length).toBe(2);
101+
102+
fp.pubkeys = [];
103+
expect(fp.pubkeys).toEqual([]);
104+
expect(fp.tags.find(t => t[0] === "p")).toBeUndefined();
105+
});
106+
107+
it("should get/set description", () => {
108+
const fp = new NDKFollowPack();
109+
expect(fp.description).toBeUndefined();
110+
111+
fp.description = "A follow pack description";
112+
expect(fp.description).toBe("A follow pack description");
113+
expect(fp.tags.find(t => t[0] === "description")).toEqual(["description", "A follow pack description"]);
114+
115+
fp.description = undefined;
116+
expect(fp.description).toBeUndefined();
117+
expect(fp.tags.find(t => t[0] === "description")).toBeUndefined();
118+
});
119+
});
120+
121+
describe("Edge Cases", () => {
122+
it("should remove tags when setting undefined values", () => {
123+
const fp = new NDKFollowPack();
124+
fp.title = "Test";
125+
expect(fp.tags.find(t => t[0] === "title")).toBeDefined();
126+
127+
fp.title = undefined;
128+
expect(fp.tags.find(t => t[0] === "title")).toBeUndefined();
129+
});
130+
131+
it("should remove all p tags when setting pubkeys to empty array", () => {
132+
const fp = new NDKFollowPack();
133+
fp.pubkeys = ["pk1", "pk2"];
134+
expect(fp.tags.filter(t => t[0] === "p").length).toBe(2);
135+
136+
fp.pubkeys = [];
137+
expect(fp.tags.find(t => t[0] === "p")).toBeUndefined();
138+
});
139+
140+
it("should handle malformed or missing tags gracefully", () => {
141+
const fp = new NDKFollowPack();
142+
// Manually add malformed tags
143+
fp.tags.push(["p"]);
144+
fp.tags.push(["image"]);
145+
fp.tags.push(["imeta"]);
146+
fp.tags.push(["title"]);
147+
fp.tags.push(["description"]);
148+
149+
// Getters should not throw and should return undefined or empty
150+
expect(fp.pubkeys).toEqual([]);
151+
expect(fp.image).toBeUndefined();
152+
expect(fp.title).toBeUndefined();
153+
expect(fp.description).toBeUndefined();
154+
});
155+
});
156+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { NDK } from "../../ndk/index.js";
2+
import type { NostrEvent } from "../index.js";
3+
import { NDKEvent } from "../index.js";
4+
import { NDKKind } from "./index.js";
5+
import type { NDKImetaTag } from "../../utils/imeta.js";
6+
import { imetaTagToTag, mapImetaTag } from "../../utils/imeta.js";
7+
8+
/**
9+
* Represents a FollowPack or MediaFollowPack event.
10+
* @group Kind Wrapper
11+
*/
12+
export class NDKFollowPack extends NDKEvent {
13+
static kind = NDKKind.FollowPack;
14+
static kinds = [NDKKind.FollowPack, NDKKind.MediaFollowPack];
15+
16+
constructor(ndk?: NDK, rawEvent?: NostrEvent) {
17+
super(ndk, rawEvent);
18+
this.kind ??= NDKKind.FollowPack;
19+
}
20+
21+
/**
22+
* Converts a generic NDKEvent to an NDKFollowPack.
23+
*/
24+
static from(ndkEvent: NDKEvent): NDKFollowPack {
25+
return new NDKFollowPack(ndkEvent.ndk, ndkEvent.rawEvent());
26+
}
27+
28+
/**
29+
* Gets the title from the tags.
30+
*/
31+
get title(): string | undefined {
32+
return this.tagValue("title");
33+
}
34+
35+
/**
36+
* Sets the title tag.
37+
*/
38+
set title(value: string | undefined) {
39+
this.setTag("title", value);
40+
}
41+
42+
/**
43+
* Gets the image URL from the tags.
44+
*/
45+
/**
46+
* Gets the image URL from the tags.
47+
* Looks for an imeta tag first (returns its url), then falls back to the image tag.
48+
*/
49+
get image(): string | undefined {
50+
// Look for an "imeta" tag first
51+
const imetaTag = this.tags.find(tag => tag[0] === "imeta");
52+
if (imetaTag) {
53+
const imeta = mapImetaTag(imetaTag);
54+
if (imeta.url) return imeta.url;
55+
}
56+
// Fallback to "image" tag
57+
return this.tagValue("image");
58+
}
59+
60+
/**
61+
* Sets the image URL tag.
62+
*/
63+
/**
64+
* Sets the image tag.
65+
* Accepts a string (URL) or an NDKImetaTag.
66+
* If given an NDKImetaTag, sets both the imeta tag and the image tag (using the url).
67+
* If undefined, removes both tags.
68+
*/
69+
set image(value: string | NDKImetaTag | undefined) {
70+
// Remove existing "imeta" and "image" tags
71+
this.tags = this.tags.filter(tag => tag[0] !== "imeta" && tag[0] !== "image");
72+
73+
if (typeof value === "string") {
74+
if (value !== undefined) {
75+
this.tags.push(["image", value]);
76+
}
77+
} else if (value && typeof value === "object") {
78+
// Set imeta tag
79+
this.tags.push(imetaTagToTag(value));
80+
// Also set image tag if url is present
81+
if (value.url) {
82+
this.tags.push(["image", value.url]);
83+
}
84+
}
85+
// If value is undefined, both tags are already removed
86+
}
87+
88+
/**
89+
* Gets all pubkeys from p tags.
90+
*/
91+
get pubkeys(): string[] {
92+
return this.tags
93+
.filter(tag => tag[0] === "p" && typeof tag[1] === "string")
94+
.map(tag => tag[1]);
95+
}
96+
97+
/**
98+
* Sets the pubkeys (replaces all p tags).
99+
*/
100+
set pubkeys(pubkeys: string[]) {
101+
this.tags = this.tags.filter(tag => tag[0] !== "p");
102+
for (const pubkey of pubkeys) {
103+
this.tags.push(["p", pubkey]);
104+
}
105+
}
106+
107+
/**
108+
* Gets the description from the tags.
109+
*/
110+
get description(): string | undefined {
111+
return this.tagValue("description");
112+
}
113+
114+
/**
115+
* Sets the description tag.
116+
*/
117+
set description(value: string | undefined) {
118+
this.setTag("description", value);
119+
}
120+
121+
/**
122+
* Helper to set or update a tag.
123+
*/
124+
protected setTag(tagName: string, value: string | undefined) {
125+
this.tags = this.tags.filter(tag => tag[0] !== tagName);
126+
if (value !== undefined) {
127+
this.tags.push([tagName, value]);
128+
}
129+
}
130+
}

ndk-core/src/events/kinds/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ export enum NDKKind {
174174
GroupAdmins = 39001, // NIP-29
175175
GroupMembers = 39002, // NIP-29
176176

177+
FollowPack = 39089,
178+
MediaFollowPack = 39092,
179+
177180
// NIP-89: App Metadata
178181
AppRecommendation = 31989,
179182
AppHandler = 31990,

ndk-core/src/events/wrap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { NDKSubscriptionTier } from "./kinds/subscriptions/tier.js";
1313
import { NDKVideo } from "./kinds/video.js";
1414
import { NDKWiki } from "./kinds/wiki.js";
1515
import { NDKBlossomList } from "./kinds/blossom-list.js";
16+
import { NDKFollowPack } from "./kinds/follow-pack.js";
1617

1718
export function wrapEvent<T extends NDKEvent>(event: NDKEvent): T | Promise<T> | NDKEvent {
1819
const eventWrappingMap = new Map();
@@ -31,6 +32,7 @@ export function wrapEvent<T extends NDKEvent>(event: NDKEvent): T | Promise<T> |
3132
NDKList,
3233
NDKStory,
3334
NDKBlossomList,
35+
NDKFollowPack,
3436
]) {
3537
for (const kind of klass.kinds) {
3638
eventWrappingMap.set(kind, klass);

0 commit comments

Comments
 (0)