@blocknote/core 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/blocknote.cjs +12 -0
  2. package/dist/blocknote.cjs.map +1 -0
  3. package/dist/blocknote.js +5028 -3444
  4. package/dist/blocknote.js.map +1 -1
  5. package/dist/comments.cjs +2 -0
  6. package/dist/comments.cjs.map +1 -0
  7. package/dist/comments.js +593 -0
  8. package/dist/comments.js.map +1 -0
  9. package/dist/style.css +1 -1
  10. package/dist/tsconfig.tsbuildinfo +1 -1
  11. package/dist/webpack-stats.json +1 -1
  12. package/package.json +39 -26
  13. package/src/api/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap +1022 -378
  14. package/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap +730 -270
  15. package/src/api/blockManipulation/commands/moveBlocks/__snapshots__/moveBlocks.test.ts.snap +3100 -1260
  16. package/src/api/blockManipulation/commands/removeBlocks/__snapshots__/removeBlocks.test.ts.snap +438 -162
  17. package/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap +1168 -432
  18. package/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap +930 -378
  19. package/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap +2485 -1015
  20. package/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts +28 -1
  21. package/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +1 -1
  22. package/src/api/blockManipulation/selections/__snapshots__/selection.test.ts.snap +292 -108
  23. package/src/api/blockManipulation/setupTestEnv.ts +14 -1
  24. package/src/api/blockManipulation/tables/tables.test.ts +1987 -0
  25. package/src/api/blockManipulation/tables/tables.ts +887 -0
  26. package/src/api/clipboard/__snapshots__/external/pasteEndOfParagraph.html +66 -24
  27. package/src/api/clipboard/__snapshots__/external/pasteEndOfParagraphText.html +66 -24
  28. package/src/api/clipboard/__snapshots__/external/pasteImage.html +66 -24
  29. package/src/api/clipboard/__snapshots__/external/pasteParagraphInCustomBlock.html +66 -24
  30. package/src/api/clipboard/__snapshots__/external/pasteTable.html +132 -48
  31. package/src/api/clipboard/__snapshots__/external/pasteTableInExistingTable.html +136 -44
  32. package/src/api/clipboard/fromClipboard/handleFileInsertion.ts +36 -14
  33. package/src/api/clipboard/toClipboard/copyExtension.ts +2 -3
  34. package/src/api/exporters/html/__snapshots__/table/headerCols/external.html +1 -0
  35. package/src/api/exporters/html/__snapshots__/table/headerCols/internal.html +1 -0
  36. package/src/api/exporters/html/__snapshots__/table/headerRows/external.html +1 -0
  37. package/src/api/exporters/html/__snapshots__/table/headerRows/internal.html +1 -0
  38. package/src/api/exporters/html/__snapshots__/table/headersRows/external.html +1 -0
  39. package/src/api/exporters/html/__snapshots__/table/headersRows/internal.html +1 -0
  40. package/src/api/exporters/html/__snapshots__/table/mixedCellColors/external.html +1 -0
  41. package/src/api/exporters/html/__snapshots__/table/mixedCellColors/internal.html +1 -0
  42. package/src/api/exporters/html/__snapshots__/table/mixedRowspansAndColspans/external.html +1 -0
  43. package/src/api/exporters/html/__snapshots__/table/mixedRowspansAndColspans/internal.html +1 -0
  44. package/src/api/exporters/markdown/__snapshots__/table/headerCols/markdown.md +4 -0
  45. package/src/api/exporters/markdown/__snapshots__/table/headerRows/markdown.md +4 -0
  46. package/src/api/exporters/markdown/__snapshots__/table/mixedCellColors/markdown.md +5 -0
  47. package/src/api/exporters/markdown/__snapshots__/table/mixedRowspansAndColspans/markdown.md +5 -0
  48. package/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap +985 -20
  49. package/src/api/nodeConversions/blockToNode.ts +63 -20
  50. package/src/api/nodeConversions/nodeToBlock.ts +75 -13
  51. package/src/api/parsers/html/__snapshots__/parse-notion-html.json +145 -54
  52. package/src/api/testUtil/cases/defaultSchema.ts +782 -9
  53. package/src/api/testUtil/partialBlockTestUtil.ts +39 -4
  54. package/src/blocks/TableBlockContent/TableBlockContent.ts +11 -5
  55. package/src/blocks/defaultBlockTypeGuards.ts +8 -0
  56. package/src/comments/index.ts +9 -0
  57. package/src/comments/models/User.ts +8 -0
  58. package/src/comments/threadstore/DefaultThreadStoreAuth.ts +106 -0
  59. package/src/comments/threadstore/ThreadStore.ts +134 -0
  60. package/src/comments/threadstore/ThreadStoreAuth.ts +13 -0
  61. package/src/comments/threadstore/TipTapThreadStore.ts +292 -0
  62. package/src/comments/threadstore/yjs/RESTYjsThreadStore.ts +144 -0
  63. package/src/comments/threadstore/yjs/YjsThreadStore.test.ts +294 -0
  64. package/src/comments/threadstore/yjs/YjsThreadStore.ts +340 -0
  65. package/src/comments/threadstore/yjs/YjsThreadStoreBase.ts +48 -0
  66. package/src/comments/threadstore/yjs/yjsHelpers.ts +121 -0
  67. package/src/comments/types.ts +117 -0
  68. package/src/editor/Block.css +16 -8
  69. package/src/editor/BlockNoteEditor.ts +269 -92
  70. package/src/editor/BlockNoteExtensions.ts +24 -1
  71. package/src/editor/BlockNoteTipTapEditor.ts +5 -1
  72. package/src/editor/editor.css +17 -0
  73. package/src/extensions/BackgroundColor/BackgroundColorExtension.ts +1 -1
  74. package/src/extensions/Comments/CommentMark.ts +61 -0
  75. package/src/extensions/Comments/CommentsPlugin.ts +301 -0
  76. package/src/extensions/Comments/userstore/UserStore.ts +72 -0
  77. package/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +16 -10
  78. package/src/extensions/LinkToolbar/LinkToolbarPlugin.ts +3 -3
  79. package/src/extensions/ShowSelection/ShowSelectionPlugin.ts +52 -0
  80. package/src/extensions/SideMenu/SideMenuPlugin.ts +22 -9
  81. package/src/extensions/TableHandles/TableHandlesPlugin.ts +409 -57
  82. package/src/extensions/TextAlignment/TextAlignmentExtension.ts +2 -0
  83. package/src/extensions/TextColor/TextColorExtension.ts +1 -1
  84. package/src/extensions/UniqueID/UniqueID.ts +8 -3
  85. package/src/i18n/locales/ar.ts +23 -0
  86. package/src/i18n/locales/de.ts +15 -0
  87. package/src/i18n/locales/en.ts +25 -1
  88. package/src/i18n/locales/es.ts +16 -1
  89. package/src/i18n/locales/fr.ts +23 -0
  90. package/src/i18n/locales/hr.ts +18 -0
  91. package/src/i18n/locales/index.ts +1 -0
  92. package/src/i18n/locales/is.ts +24 -1
  93. package/src/i18n/locales/it.ts +21 -0
  94. package/src/i18n/locales/ja.ts +23 -0
  95. package/src/i18n/locales/ko.ts +23 -0
  96. package/src/i18n/locales/nl.ts +23 -0
  97. package/src/i18n/locales/no.ts +346 -0
  98. package/src/i18n/locales/pl.ts +23 -0
  99. package/src/i18n/locales/pt.ts +23 -0
  100. package/src/i18n/locales/ru.ts +23 -0
  101. package/src/i18n/locales/uk.ts +23 -0
  102. package/src/i18n/locales/vi.ts +23 -0
  103. package/src/i18n/locales/zh.ts +23 -0
  104. package/src/index.ts +6 -4
  105. package/src/schema/blocks/types.ts +32 -2
  106. package/src/util/browser.ts +1 -1
  107. package/src/util/table.ts +107 -0
  108. package/types/src/api/blockManipulation/tables/tables.d.ts +343 -0
  109. package/types/src/api/blockManipulation/tables/tables.test.d.ts +1 -0
  110. package/types/src/api/clipboard/toClipboard/copyExtension.d.ts +1 -1
  111. package/types/src/blocks/TableBlockContent/TableBlockContent.d.ts +1 -2
  112. package/types/src/blocks/defaultBlockTypeGuards.d.ts +3 -0
  113. package/types/src/comments/index.d.ts +9 -0
  114. package/types/src/comments/models/User.d.ts +8 -0
  115. package/types/src/comments/threadstore/DefaultThreadStoreAuth.d.ts +47 -0
  116. package/types/src/comments/threadstore/ThreadStore.d.ts +121 -0
  117. package/types/src/comments/threadstore/ThreadStoreAuth.d.ts +12 -0
  118. package/types/src/comments/threadstore/TipTapThreadStore.d.ts +97 -0
  119. package/types/src/comments/threadstore/yjs/RESTYjsThreadStore.d.ts +83 -0
  120. package/types/src/comments/threadstore/yjs/YjsThreadStore.d.ts +79 -0
  121. package/types/src/comments/threadstore/yjs/YjsThreadStore.test.d.ts +1 -0
  122. package/types/src/comments/threadstore/yjs/YjsThreadStoreBase.d.ts +15 -0
  123. package/types/src/comments/threadstore/yjs/yjsHelpers.d.ts +13 -0
  124. package/types/src/comments/types.d.ts +109 -0
  125. package/types/src/editor/BlockNoteEditor.d.ts +146 -66
  126. package/types/src/editor/BlockNoteExtensions.d.ts +4 -0
  127. package/types/src/extensions/Collaboration/createCollaborationExtensions.d.ts +1 -1
  128. package/types/src/extensions/Comments/CommentMark.d.ts +2 -0
  129. package/types/src/extensions/Comments/CommentsPlugin.d.ts +49 -0
  130. package/types/src/extensions/Comments/userstore/UserStore.d.ts +31 -0
  131. package/types/src/extensions/FormattingToolbar/FormattingToolbarPlugin.d.ts +1 -1
  132. package/types/src/extensions/ShowSelection/ShowSelectionPlugin.d.ts +15 -0
  133. package/types/src/extensions/SideMenu/SideMenuPlugin.d.ts +1 -0
  134. package/types/src/extensions/TableHandles/TableHandlesPlugin.d.ts +66 -1
  135. package/types/src/i18n/locales/de.d.ts +15 -0
  136. package/types/src/i18n/locales/en.d.ts +20 -0
  137. package/types/src/i18n/locales/es.d.ts +15 -0
  138. package/types/src/i18n/locales/hr.d.ts +18 -0
  139. package/types/src/i18n/locales/index.d.ts +1 -0
  140. package/types/src/i18n/locales/it.d.ts +21 -0
  141. package/types/src/i18n/locales/no.d.ts +2 -0
  142. package/types/src/index.d.ts +5 -4
  143. package/types/src/pm-nodes/BlockContainer.d.ts +2 -2
  144. package/types/src/pm-nodes/BlockGroup.d.ts +2 -2
  145. package/types/src/schema/blocks/types.d.ts +23 -2
  146. package/types/src/util/browser.d.ts +1 -1
  147. package/types/src/util/table.d.ts +12 -0
  148. package/dist/blocknote.umd.cjs +0 -11
  149. package/dist/blocknote.umd.cjs.map +0 -1
@@ -0,0 +1,144 @@
1
+ import * as Y from "yjs";
2
+ import { CommentBody } from "../../types.js";
3
+ import { ThreadStoreAuth } from "../ThreadStoreAuth.js";
4
+ import { YjsThreadStoreBase } from "./YjsThreadStoreBase.js";
5
+
6
+ /**
7
+ * This is a REST-based implementation of the YjsThreadStoreBase.
8
+ * It Reads data directly from the underlying document (same as YjsThreadStore),
9
+ * but for Writes, it sends data to a REST API that should:
10
+ * - check the user has the correct permissions to make the desired changes
11
+ * - apply the updates to the underlying Yjs document
12
+ *
13
+ * (see https://github.com/TypeCellOS/BlockNote-demo-nextjs-hocuspocus)
14
+ *
15
+ * The reason we still use the Yjs document as underlying storage is that it makes it easy to
16
+ * sync updates in real-time to other collaborators.
17
+ * (but technically, you could also implement a different storage altogether
18
+ * and not store the thread related data in the Yjs document)
19
+ */
20
+ export class RESTYjsThreadStore extends YjsThreadStoreBase {
21
+ constructor(
22
+ private readonly BASE_URL: string,
23
+ private readonly headers: Record<string, string>,
24
+ threadsYMap: Y.Map<any>,
25
+ auth: ThreadStoreAuth
26
+ ) {
27
+ super(threadsYMap, auth);
28
+ }
29
+
30
+ private doRequest = async (path: string, method: string, body?: any) => {
31
+ const response = await fetch(`${this.BASE_URL}${path}`, {
32
+ method,
33
+ body: JSON.stringify(body),
34
+ headers: {
35
+ "Content-Type": "application/json",
36
+ ...this.headers,
37
+ },
38
+ });
39
+
40
+ if (!response.ok) {
41
+ throw new Error(`Failed to ${method} ${path}: ${response.statusText}`);
42
+ }
43
+
44
+ return response.json();
45
+ };
46
+
47
+ public addThreadToDocument = async (options: {
48
+ threadId: string;
49
+ selection: {
50
+ prosemirror: {
51
+ head: number;
52
+ anchor: number;
53
+ };
54
+ yjs: {
55
+ head: any;
56
+ anchor: any;
57
+ };
58
+ };
59
+ }) => {
60
+ const { threadId, ...rest } = options;
61
+ return this.doRequest(`/${threadId}/addToDocument`, "POST", rest);
62
+ };
63
+
64
+ public createThread = async (options: {
65
+ initialComment: {
66
+ body: CommentBody;
67
+ metadata?: any;
68
+ };
69
+ metadata?: any;
70
+ }) => {
71
+ return this.doRequest("", "POST", options);
72
+ };
73
+
74
+ public addComment = (options: {
75
+ comment: {
76
+ body: CommentBody;
77
+ metadata?: any;
78
+ };
79
+ threadId: string;
80
+ }) => {
81
+ const { threadId, ...rest } = options;
82
+ return this.doRequest(`/${threadId}/comments`, "POST", rest);
83
+ };
84
+
85
+ public updateComment = (options: {
86
+ comment: {
87
+ body: CommentBody;
88
+ metadata?: any;
89
+ };
90
+ threadId: string;
91
+ commentId: string;
92
+ }) => {
93
+ const { threadId, commentId, ...rest } = options;
94
+ return this.doRequest(`/${threadId}/comments/${commentId}`, "PUT", rest);
95
+ };
96
+
97
+ public deleteComment = (options: {
98
+ threadId: string;
99
+ commentId: string;
100
+ softDelete?: boolean;
101
+ }) => {
102
+ const { threadId, commentId, ...rest } = options;
103
+ return this.doRequest(
104
+ `/${threadId}/comments/${commentId}?soft=${!!rest.softDelete}`,
105
+ "DELETE"
106
+ );
107
+ };
108
+
109
+ public deleteThread = (options: { threadId: string }) => {
110
+ return this.doRequest(`/${options.threadId}`, "DELETE");
111
+ };
112
+
113
+ public resolveThread = (options: { threadId: string }) => {
114
+ return this.doRequest(`/${options.threadId}/resolve`, "POST");
115
+ };
116
+
117
+ public unresolveThread = (options: { threadId: string }) => {
118
+ return this.doRequest(`/${options.threadId}/unresolve`, "POST");
119
+ };
120
+
121
+ public addReaction = (options: {
122
+ threadId: string;
123
+ commentId: string;
124
+ emoji: string;
125
+ }) => {
126
+ const { threadId, commentId, ...rest } = options;
127
+ return this.doRequest(
128
+ `/${threadId}/comments/${commentId}/reactions`,
129
+ "POST",
130
+ rest
131
+ );
132
+ };
133
+
134
+ public deleteReaction = (options: {
135
+ threadId: string;
136
+ commentId: string;
137
+ emoji: string;
138
+ }) => {
139
+ return this.doRequest(
140
+ `/${options.threadId}/comments/${options.commentId}/reactions/${options.emoji}`,
141
+ "DELETE"
142
+ );
143
+ };
144
+ }
@@ -0,0 +1,294 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import * as Y from "yjs";
3
+ import { CommentBody } from "../../types.js";
4
+ import { DefaultThreadStoreAuth } from "../DefaultThreadStoreAuth.js";
5
+ import { YjsThreadStore } from "./YjsThreadStore.js";
6
+
7
+ // Mock UUID to generate sequential IDs
8
+ let mockUuidCounter = 0;
9
+ vi.mock("uuid", () => ({
10
+ v4: () => `mocked-uuid-${++mockUuidCounter}`,
11
+ }));
12
+
13
+ describe("YjsThreadStore", () => {
14
+ let store: YjsThreadStore;
15
+ let doc: Y.Doc;
16
+ let threadsYMap: Y.Map<any>;
17
+
18
+ beforeEach(() => {
19
+ // Reset mocks and create fresh instances
20
+ vi.clearAllMocks();
21
+ mockUuidCounter = 0;
22
+ doc = new Y.Doc();
23
+ threadsYMap = doc.getMap("threads");
24
+
25
+ store = new YjsThreadStore(
26
+ "test-user",
27
+ threadsYMap,
28
+ new DefaultThreadStoreAuth("test-user", "editor")
29
+ );
30
+ });
31
+
32
+ describe("createThread", () => {
33
+ it("creates a thread with initial comment", async () => {
34
+ const initialComment = {
35
+ body: "Test comment" as CommentBody,
36
+ metadata: { extra: "metadatacomment" },
37
+ };
38
+
39
+ const thread = await store.createThread({
40
+ initialComment,
41
+ metadata: { extra: "metadatathread" },
42
+ });
43
+
44
+ expect(thread).toMatchObject({
45
+ type: "thread",
46
+ id: "mocked-uuid-2",
47
+ resolved: false,
48
+ metadata: { extra: "metadatathread" },
49
+ comments: [
50
+ {
51
+ type: "comment",
52
+ id: "mocked-uuid-1",
53
+ userId: "test-user",
54
+ body: "Test comment",
55
+ metadata: { extra: "metadatacomment" },
56
+ reactions: [],
57
+ },
58
+ ],
59
+ });
60
+ });
61
+ });
62
+
63
+ describe("addComment", () => {
64
+ it("adds a comment to existing thread", async () => {
65
+ // First create a thread
66
+ const thread = await store.createThread({
67
+ initialComment: {
68
+ body: "Initial comment" as CommentBody,
69
+ },
70
+ });
71
+
72
+ // Add new comment
73
+ const comment = await store.addComment({
74
+ threadId: thread.id,
75
+ comment: {
76
+ body: "New comment" as CommentBody,
77
+ metadata: { test: "metadata" },
78
+ },
79
+ });
80
+
81
+ expect(comment).toMatchObject({
82
+ type: "comment",
83
+ id: "mocked-uuid-3",
84
+ userId: "test-user",
85
+ body: "New comment",
86
+ metadata: { test: "metadata" },
87
+ reactions: [],
88
+ });
89
+
90
+ // Verify thread has both comments
91
+ const updatedThread = store.getThread(thread.id);
92
+ expect(updatedThread.comments).toHaveLength(2);
93
+ });
94
+
95
+ it("throws error for non-existent thread", async () => {
96
+ await expect(
97
+ store.addComment({
98
+ threadId: "non-existent",
99
+ comment: {
100
+ body: "Test comment" as CommentBody,
101
+ },
102
+ })
103
+ ).rejects.toThrow("Thread not found");
104
+ });
105
+ });
106
+
107
+ describe("updateComment", () => {
108
+ it("updates existing comment", async () => {
109
+ const thread = await store.createThread({
110
+ initialComment: {
111
+ body: "Initial comment" as CommentBody,
112
+ },
113
+ });
114
+
115
+ await store.updateComment({
116
+ threadId: thread.id,
117
+ commentId: thread.comments[0].id,
118
+ comment: {
119
+ body: "Updated comment" as CommentBody,
120
+ metadata: { updatedMetadata: true },
121
+ },
122
+ });
123
+
124
+ const updatedThread = store.getThread(thread.id);
125
+ expect(updatedThread.comments[0]).toMatchObject({
126
+ body: "Updated comment",
127
+ metadata: { updatedMetadata: true },
128
+ });
129
+ });
130
+ });
131
+
132
+ describe("deleteComment", () => {
133
+ it("soft deletes a comment", async () => {
134
+ const thread = await store.createThread({
135
+ initialComment: {
136
+ body: "Test comment" as CommentBody,
137
+ },
138
+ });
139
+
140
+ await store.deleteComment({
141
+ threadId: thread.id,
142
+ commentId: thread.comments[0].id,
143
+ softDelete: true,
144
+ });
145
+
146
+ const updatedThread = store.getThread(thread.id);
147
+ expect(updatedThread.comments[0].deletedAt).toBeDefined();
148
+ expect(updatedThread.comments[0].body).toBeUndefined();
149
+ });
150
+
151
+ it("hard deletes a comment (deletes thread)", async () => {
152
+ const thread = await store.createThread({
153
+ initialComment: {
154
+ body: "Test comment" as CommentBody,
155
+ },
156
+ });
157
+
158
+ await store.deleteComment({
159
+ threadId: thread.id,
160
+ commentId: thread.comments[0].id,
161
+ softDelete: false,
162
+ });
163
+
164
+ // Thread should be deleted since it was the only comment
165
+ expect(() => store.getThread(thread.id)).toThrow("Thread not found");
166
+ });
167
+ });
168
+
169
+ describe("resolveThread", () => {
170
+ it("resolves a thread", async () => {
171
+ const thread = await store.createThread({
172
+ initialComment: {
173
+ body: "Test comment" as CommentBody,
174
+ },
175
+ });
176
+
177
+ await store.resolveThread({ threadId: thread.id });
178
+
179
+ const updatedThread = store.getThread(thread.id);
180
+ expect(updatedThread.resolved).toBe(true);
181
+ expect(updatedThread.resolvedUpdatedAt).toBeDefined();
182
+ });
183
+ });
184
+
185
+ describe("unresolveThread", () => {
186
+ it("unresolves a thread", async () => {
187
+ const thread = await store.createThread({
188
+ initialComment: {
189
+ body: "Test comment" as CommentBody,
190
+ },
191
+ });
192
+
193
+ await store.resolveThread({ threadId: thread.id });
194
+ await store.unresolveThread({ threadId: thread.id });
195
+
196
+ const updatedThread = store.getThread(thread.id);
197
+ expect(updatedThread.resolved).toBe(false);
198
+ expect(updatedThread.resolvedUpdatedAt).toBeDefined();
199
+ });
200
+ });
201
+
202
+ describe("getThreads", () => {
203
+ it("returns all threads", async () => {
204
+ await store.createThread({
205
+ initialComment: {
206
+ body: "Thread 1" as CommentBody,
207
+ },
208
+ });
209
+
210
+ await store.createThread({
211
+ initialComment: {
212
+ body: "Thread 2" as CommentBody,
213
+ },
214
+ });
215
+
216
+ const threads = store.getThreads();
217
+ expect(threads.size).toBe(2);
218
+ });
219
+ });
220
+
221
+ describe("deleteThread", () => {
222
+ it("deletes an entire thread", async () => {
223
+ const thread = await store.createThread({
224
+ initialComment: {
225
+ body: "Test comment" as CommentBody,
226
+ },
227
+ });
228
+
229
+ await store.deleteThread({ threadId: thread.id });
230
+
231
+ // Verify thread is deleted
232
+ expect(() => store.getThread(thread.id)).toThrow("Thread not found");
233
+ });
234
+ });
235
+
236
+ describe("reactions", () => {
237
+ it("adds a reaction to a comment", async () => {
238
+ const thread = await store.createThread({
239
+ initialComment: {
240
+ body: "Test comment" as CommentBody,
241
+ },
242
+ });
243
+
244
+ await store.addReaction({
245
+ threadId: thread.id,
246
+ commentId: thread.comments[0].id,
247
+ emoji: "👍",
248
+ });
249
+
250
+ expect(store.getThread(thread.id).comments[0].reactions).toHaveLength(1);
251
+ });
252
+
253
+ it("deletes a reaction from a comment", async () => {
254
+ const thread = await store.createThread({
255
+ initialComment: {
256
+ body: "Test comment" as CommentBody,
257
+ },
258
+ });
259
+
260
+ await store.addReaction({
261
+ threadId: thread.id,
262
+ commentId: thread.comments[0].id,
263
+ emoji: "👍",
264
+ });
265
+
266
+ expect(store.getThread(thread.id).comments[0].reactions).toHaveLength(1);
267
+
268
+ await store.deleteReaction({
269
+ threadId: thread.id,
270
+ commentId: thread.comments[0].id,
271
+ emoji: "👍",
272
+ });
273
+
274
+ expect(store.getThread(thread.id).comments[0].reactions).toHaveLength(0);
275
+ });
276
+ });
277
+
278
+ describe("subscribe", () => {
279
+ it("calls callback when threads change", async () => {
280
+ const callback = vi.fn();
281
+ const unsubscribe = store.subscribe(callback);
282
+
283
+ await store.createThread({
284
+ initialComment: {
285
+ body: "Test comment" as CommentBody,
286
+ },
287
+ });
288
+
289
+ expect(callback).toHaveBeenCalled();
290
+
291
+ unsubscribe();
292
+ });
293
+ });
294
+ });