@codingfactory/socialkit-vue 0.4.0 → 0.5.1

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.
@@ -0,0 +1,1857 @@
1
+ /**
2
+ * Generic discussion store factory for SocialKit-powered frontends.
3
+ */
4
+ import { isAxiosError } from 'axios';
5
+ import { defineStore } from 'pinia';
6
+ import { onScopeDispose, ref } from 'vue';
7
+ function isRecord(value) {
8
+ return typeof value === 'object' && value !== null;
9
+ }
10
+ function getStringValue(source, key) {
11
+ if (!isRecord(source)) {
12
+ return null;
13
+ }
14
+ const candidate = source[key];
15
+ return typeof candidate === 'string' ? candidate : null;
16
+ }
17
+ function toRecord(value) {
18
+ return isRecord(value) ? value : null;
19
+ }
20
+ function getErrorResponseData(error) {
21
+ if (!isAxiosError(error)) {
22
+ return null;
23
+ }
24
+ return toRecord(error.response?.data);
25
+ }
26
+ function getErrorMessage(error, fallback) {
27
+ if (isAxiosError(error)) {
28
+ const responseData = getErrorResponseData(error);
29
+ const message = typeof responseData?.message === 'string' && responseData.message.trim().length > 0
30
+ ? responseData.message
31
+ : null;
32
+ if (message) {
33
+ return message;
34
+ }
35
+ }
36
+ if (error instanceof Error && error.message.trim().length > 0) {
37
+ return error.message;
38
+ }
39
+ return fallback;
40
+ }
41
+ function getThreadsFromPayload(payload) {
42
+ if (!isRecord(payload)) {
43
+ return [];
44
+ }
45
+ const topLevelItems = payload.items;
46
+ if (Array.isArray(topLevelItems)) {
47
+ return topLevelItems;
48
+ }
49
+ const dataPayload = payload.data;
50
+ if (Array.isArray(dataPayload)) {
51
+ return dataPayload;
52
+ }
53
+ if (!isRecord(dataPayload)) {
54
+ return [];
55
+ }
56
+ const nestedItems = dataPayload.items;
57
+ if (Array.isArray(nestedItems)) {
58
+ return nestedItems;
59
+ }
60
+ return [];
61
+ }
62
+ function getThreadNextCursorFromPayload(payload) {
63
+ if (!isRecord(payload)) {
64
+ return null;
65
+ }
66
+ const topLevelCursor = getStringValue(payload, 'next_cursor');
67
+ if (topLevelCursor) {
68
+ return topLevelCursor;
69
+ }
70
+ const topLevelMeta = payload.meta;
71
+ const topLevelMetaCursor = getStringValue(topLevelMeta, 'next_cursor');
72
+ if (topLevelMetaCursor) {
73
+ return topLevelMetaCursor;
74
+ }
75
+ if (isRecord(topLevelMeta)) {
76
+ const topLevelMetaPaginationCursor = getStringValue(topLevelMeta.pagination, 'next_cursor');
77
+ if (topLevelMetaPaginationCursor) {
78
+ return topLevelMetaPaginationCursor;
79
+ }
80
+ }
81
+ const dataPayload = payload.data;
82
+ if (!isRecord(dataPayload)) {
83
+ return null;
84
+ }
85
+ const nestedCursor = getStringValue(dataPayload, 'next_cursor');
86
+ if (nestedCursor) {
87
+ return nestedCursor;
88
+ }
89
+ const nestedMeta = dataPayload.meta;
90
+ const nestedMetaCursor = getStringValue(nestedMeta, 'next_cursor');
91
+ if (nestedMetaCursor) {
92
+ return nestedMetaCursor;
93
+ }
94
+ if (isRecord(nestedMeta)) {
95
+ const nestedMetaPaginationCursor = getStringValue(nestedMeta.pagination, 'next_cursor');
96
+ if (nestedMetaPaginationCursor) {
97
+ return nestedMetaPaginationCursor;
98
+ }
99
+ }
100
+ return getStringValue(dataPayload.pagination, 'next_cursor');
101
+ }
102
+ function isTransientThreadListError(error) {
103
+ if (!isAxiosError(error)) {
104
+ return false;
105
+ }
106
+ const status = error.response?.status;
107
+ return status === 502 || status === 503 || status === 504;
108
+ }
109
+ export function createDiscussionStoreDefinition(config) {
110
+ const { client, getCurrentUserId, getEcho, onEchoReconnected, createLoadingState, logger = console, storeId = 'discussion', } = config;
111
+ return defineStore(storeId, () => {
112
+ const spaces = ref([]);
113
+ const spaceTree = ref([]);
114
+ const currentSpace = ref(null);
115
+ const spaceMemberships = ref({});
116
+ const spaceMembershipLoading = ref({});
117
+ const threads = ref([]);
118
+ const currentThread = ref(null);
119
+ const replies = ref([]);
120
+ const spacesLoadingState = createLoadingState();
121
+ const threadsLoadingState = createLoadingState();
122
+ const repliesLoadingState = createLoadingState();
123
+ const loading = ref(false);
124
+ const error = ref(null);
125
+ const nextCursor = ref(null);
126
+ const repliesNextCursor = ref(null);
127
+ const latestSpaceSlug = ref(null);
128
+ const latestThreadId = ref(null);
129
+ let activeSpaceChannel = null;
130
+ let activeThreadChannel = null;
131
+ const locallyCreatedReplyIds = new Set();
132
+ const pendingReplyCreations = new Map();
133
+ const realtimeCountedReplyIds = new Set();
134
+ const hiddenOpeningReplyCountByThread = new Map();
135
+ const pendingLocalReactionToggles = new Map();
136
+ const pendingLocalReactionEchoes = new Map();
137
+ function getThreadActivityTimestamp(thread) {
138
+ const candidate = thread.last_activity_at || thread.created_at;
139
+ const parsed = Date.parse(candidate);
140
+ if (Number.isNaN(parsed)) {
141
+ return 0;
142
+ }
143
+ return parsed;
144
+ }
145
+ function sortThreadsForList(items) {
146
+ return items
147
+ .map((thread, index) => ({ thread, index }))
148
+ .sort((a, b) => {
149
+ if (a.thread.is_pinned !== b.thread.is_pinned) {
150
+ return a.thread.is_pinned ? -1 : 1;
151
+ }
152
+ const aTime = getThreadActivityTimestamp(a.thread);
153
+ const bTime = getThreadActivityTimestamp(b.thread);
154
+ if (aTime !== bTime) {
155
+ return bTime - aTime;
156
+ }
157
+ return a.index - b.index;
158
+ })
159
+ .map(({ thread }) => thread);
160
+ }
161
+ function applyThreadListSorting() {
162
+ threads.value = sortThreadsForList(threads.value);
163
+ }
164
+ function normalizeThreadReplyCount(thread) {
165
+ const rawReplyCount = Number.isFinite(thread.reply_count)
166
+ ? Math.max(0, Math.floor(thread.reply_count))
167
+ : 0;
168
+ const hasOpeningPostBody = typeof thread.body === 'string' && thread.body.trim().length > 0;
169
+ if (!hasOpeningPostBody || rawReplyCount === 0) {
170
+ if (rawReplyCount === thread.reply_count) {
171
+ return thread;
172
+ }
173
+ return {
174
+ ...thread,
175
+ reply_count: rawReplyCount,
176
+ };
177
+ }
178
+ return {
179
+ ...thread,
180
+ reply_count: Math.max(0, rawReplyCount - 1),
181
+ };
182
+ }
183
+ function mergeUniqueById(existingItems, incomingItems) {
184
+ const mergedItems = [...existingItems];
185
+ const indexById = new Map();
186
+ mergedItems.forEach((item, index) => {
187
+ indexById.set(item.id, index);
188
+ });
189
+ incomingItems.forEach((item) => {
190
+ const existingIndex = indexById.get(item.id);
191
+ if (existingIndex === undefined) {
192
+ indexById.set(item.id, mergedItems.length);
193
+ mergedItems.push(item);
194
+ return;
195
+ }
196
+ mergedItems[existingIndex] = item;
197
+ });
198
+ return mergedItems;
199
+ }
200
+ function extractResponsePayload(responseData) {
201
+ const responseRecord = toRecord(responseData);
202
+ if (!responseRecord) {
203
+ return responseData;
204
+ }
205
+ return Object.prototype.hasOwnProperty.call(responseRecord, 'data')
206
+ ? responseRecord.data
207
+ : responseData;
208
+ }
209
+ function getDefaultMemberCount(spaceId) {
210
+ if (currentSpace.value?.id === spaceId) {
211
+ return currentSpace.value.meta?.member_count ?? 0;
212
+ }
213
+ const matchedSpace = spaces.value.find((space) => space.id === spaceId);
214
+ if (matchedSpace) {
215
+ return matchedSpace.meta?.member_count ?? 0;
216
+ }
217
+ return 0;
218
+ }
219
+ function normalizeMembershipResponse(spaceId, payload) {
220
+ const payloadRecord = toRecord(payload);
221
+ const payloadSpaceId = typeof payloadRecord?.space_id === 'string' && payloadRecord.space_id.length > 0
222
+ ? payloadRecord.space_id
223
+ : spaceId;
224
+ const payloadIsMember = payloadRecord?.is_member;
225
+ const isMember = typeof payloadIsMember === 'boolean'
226
+ ? payloadIsMember
227
+ : false;
228
+ const payloadMemberCount = payloadRecord?.member_count;
229
+ const fallbackMemberCount = getDefaultMemberCount(spaceId);
230
+ const memberCount = typeof payloadMemberCount === 'number'
231
+ ? Math.max(0, Math.floor(payloadMemberCount))
232
+ : fallbackMemberCount;
233
+ return {
234
+ space_id: payloadSpaceId,
235
+ is_member: isMember,
236
+ member_count: memberCount,
237
+ };
238
+ }
239
+ function setSpaceMembershipLoading(spaceId, isLoading) {
240
+ spaceMembershipLoading.value = {
241
+ ...spaceMembershipLoading.value,
242
+ [spaceId]: isLoading,
243
+ };
244
+ }
245
+ function updateSpaceMemberCount(spaceId, memberCount) {
246
+ if (currentSpace.value?.id === spaceId) {
247
+ currentSpace.value = {
248
+ ...currentSpace.value,
249
+ meta: {
250
+ ...(currentSpace.value.meta ?? {}),
251
+ member_count: memberCount,
252
+ },
253
+ };
254
+ }
255
+ spaces.value = spaces.value.map((space) => {
256
+ if (space.id !== spaceId) {
257
+ return space;
258
+ }
259
+ return {
260
+ ...space,
261
+ meta: {
262
+ ...(space.meta ?? {}),
263
+ member_count: memberCount,
264
+ },
265
+ };
266
+ });
267
+ const updateTreeNodes = (nodes) => {
268
+ return nodes.map((node) => {
269
+ const nextChildren = node.children && node.children.length > 0
270
+ ? updateTreeNodes(node.children)
271
+ : node.children;
272
+ if (node.id !== spaceId) {
273
+ return nextChildren === node.children
274
+ ? node
275
+ : {
276
+ ...node,
277
+ ...(nextChildren ? { children: nextChildren } : {}),
278
+ };
279
+ }
280
+ return {
281
+ ...node,
282
+ meta: {
283
+ ...(node.meta ?? {}),
284
+ member_count: memberCount,
285
+ },
286
+ ...(nextChildren ? { children: nextChildren } : {}),
287
+ };
288
+ });
289
+ };
290
+ spaceTree.value = updateTreeNodes(spaceTree.value);
291
+ }
292
+ function setSpaceMembership(spaceId, membership) {
293
+ spaceMemberships.value = {
294
+ ...spaceMemberships.value,
295
+ [spaceId]: membership,
296
+ };
297
+ updateSpaceMemberCount(spaceId, membership.member_count);
298
+ }
299
+ function getSpaceMembership(spaceId) {
300
+ return spaceMemberships.value[spaceId] ?? null;
301
+ }
302
+ function syncThreadReplyCount(threadId, replyCount) {
303
+ if (currentThread.value?.id === threadId) {
304
+ currentThread.value = {
305
+ ...currentThread.value,
306
+ reply_count: replyCount,
307
+ };
308
+ }
309
+ const threadIndex = threads.value.findIndex((thread) => thread.id === threadId);
310
+ if (threadIndex < 0) {
311
+ return;
312
+ }
313
+ const thread = threads.value[threadIndex];
314
+ if (!thread) {
315
+ return;
316
+ }
317
+ threads.value[threadIndex] = {
318
+ ...thread,
319
+ reply_count: replyCount,
320
+ };
321
+ }
322
+ const draftTypes = {
323
+ THREAD: 'thread',
324
+ REPLY: 'reply',
325
+ };
326
+ function flattenTree(tree) {
327
+ const result = [];
328
+ function walk(nodes) {
329
+ for (const node of nodes) {
330
+ result.push(node);
331
+ if (node.children && node.children.length > 0) {
332
+ walk(node.children);
333
+ }
334
+ }
335
+ }
336
+ walk(tree);
337
+ return result;
338
+ }
339
+ function rootSpaces() {
340
+ return spaceTree.value.filter((space) => (space.depth ?? 0) === 0);
341
+ }
342
+ function leafSpaces() {
343
+ return spaces.value.filter((space) => space.is_leaf !== false);
344
+ }
345
+ async function loadSpaces() {
346
+ spacesLoadingState.setLoading(true);
347
+ loading.value = true;
348
+ error.value = null;
349
+ try {
350
+ const response = await client.get('/v1/discussion/spaces?tree=1');
351
+ const responseData = toRecord(response.data);
352
+ const treeData = Array.isArray(responseData?.items)
353
+ ? responseData.items
354
+ : Array.isArray(responseData?.data)
355
+ ? responseData.data
356
+ : [];
357
+ spaceTree.value = treeData;
358
+ spaces.value = flattenTree(treeData);
359
+ if (treeData.length === 0) {
360
+ spacesLoadingState.setEmpty(true);
361
+ }
362
+ return response.data;
363
+ }
364
+ catch (err) {
365
+ logger.error('Failed to load spaces:', err);
366
+ const errorMessage = getErrorMessage(err, 'Failed to load discussion spaces');
367
+ spacesLoadingState.setError(new Error(errorMessage));
368
+ error.value = errorMessage;
369
+ throw err;
370
+ }
371
+ finally {
372
+ spacesLoadingState.setLoading(false);
373
+ loading.value = false;
374
+ }
375
+ }
376
+ async function loadSpaceDetail(slug, options) {
377
+ latestSpaceSlug.value = slug;
378
+ try {
379
+ const response = await client.get(`/v1/discussion/spaces/${slug}/detail`, {
380
+ ...(options?.signal ? { signal: options.signal } : {}),
381
+ });
382
+ if (latestSpaceSlug.value !== slug) {
383
+ return null;
384
+ }
385
+ const responseData = toRecord(response.data);
386
+ const spaceData = (responseData?.data ?? null);
387
+ if (spaceData) {
388
+ currentSpace.value = spaceData;
389
+ }
390
+ return spaceData;
391
+ }
392
+ catch (err) {
393
+ if (options?.signal?.aborted || latestSpaceSlug.value !== slug) {
394
+ return null;
395
+ }
396
+ if (isAxiosError(err) && err.response?.status === 404) {
397
+ currentSpace.value = null;
398
+ return null;
399
+ }
400
+ logger.error('Failed to load space detail:', err);
401
+ throw err;
402
+ }
403
+ }
404
+ async function loadSpaceMembership(spaceId) {
405
+ if (!getCurrentUserId()) {
406
+ return null;
407
+ }
408
+ setSpaceMembershipLoading(spaceId, true);
409
+ try {
410
+ const response = await client.get(`/v1/discussion/spaces/${spaceId}/membership`);
411
+ const membership = normalizeMembershipResponse(spaceId, extractResponsePayload(response.data));
412
+ setSpaceMembership(spaceId, membership);
413
+ return membership;
414
+ }
415
+ catch (err) {
416
+ logger.error('Failed to load space membership:', err);
417
+ throw err;
418
+ }
419
+ finally {
420
+ setSpaceMembershipLoading(spaceId, false);
421
+ }
422
+ }
423
+ async function mutateSpaceMembership(spaceId, action) {
424
+ if (!getCurrentUserId()) {
425
+ throw new Error('You must be signed in to manage space membership.');
426
+ }
427
+ setSpaceMembershipLoading(spaceId, true);
428
+ try {
429
+ const response = action === 'join'
430
+ ? await client.post(`/v1/discussion/spaces/${spaceId}/join`)
431
+ : await client.delete(`/v1/discussion/spaces/${spaceId}/leave`);
432
+ const membership = normalizeMembershipResponse(spaceId, extractResponsePayload(response.data));
433
+ setSpaceMembership(spaceId, membership);
434
+ return membership;
435
+ }
436
+ catch (err) {
437
+ logger.error(`Failed to ${action} space membership:`, err);
438
+ throw err;
439
+ }
440
+ finally {
441
+ setSpaceMembershipLoading(spaceId, false);
442
+ }
443
+ }
444
+ async function joinSpace(spaceId) {
445
+ return mutateSpaceMembership(spaceId, 'join');
446
+ }
447
+ async function leaveSpace(spaceId) {
448
+ return mutateSpaceMembership(spaceId, 'leave');
449
+ }
450
+ async function loadThreads(spaceSlug, cursor, options) {
451
+ if (!cursor || latestSpaceSlug.value === null) {
452
+ latestSpaceSlug.value = spaceSlug;
453
+ }
454
+ threadsLoadingState.setLoading(true);
455
+ loading.value = true;
456
+ error.value = null;
457
+ let isStale = false;
458
+ try {
459
+ const queryParams = new URLSearchParams();
460
+ if (cursor) {
461
+ queryParams.set('cursor', cursor);
462
+ }
463
+ if (options?.sort) {
464
+ queryParams.set('sort', options.sort);
465
+ }
466
+ const queryString = queryParams.toString();
467
+ const url = `/v1/discussion/spaces/${spaceSlug}/threads${queryString.length > 0 ? `?${queryString}` : ''}`;
468
+ const requestConfig = {
469
+ ...(options?.signal ? { signal: options.signal } : {}),
470
+ };
471
+ let response;
472
+ try {
473
+ response = await client.get(url, requestConfig);
474
+ }
475
+ catch (requestError) {
476
+ if (!isTransientThreadListError(requestError) || options?.signal?.aborted) {
477
+ throw requestError;
478
+ }
479
+ response = await client.get(url, requestConfig);
480
+ }
481
+ if (latestSpaceSlug.value !== spaceSlug) {
482
+ isStale = true;
483
+ return response.data;
484
+ }
485
+ const newThreads = getThreadsFromPayload(response.data).map((thread) => normalizeThreadReplyCount(thread));
486
+ if (cursor) {
487
+ threads.value = mergeUniqueById(threads.value, newThreads);
488
+ applyThreadListSorting();
489
+ }
490
+ else {
491
+ threads.value = newThreads;
492
+ applyThreadListSorting();
493
+ if (newThreads.length === 0) {
494
+ threadsLoadingState.setEmpty(true);
495
+ }
496
+ }
497
+ nextCursor.value = getThreadNextCursorFromPayload(response.data);
498
+ if (!currentSpace.value || currentSpace.value.slug !== spaceSlug) {
499
+ currentSpace.value = spaces.value.find((space) => space.slug === spaceSlug) ?? null;
500
+ }
501
+ if (currentSpace.value?.id) {
502
+ subscribeToSpaceRealtime(currentSpace.value.id);
503
+ }
504
+ return response.data;
505
+ }
506
+ catch (err) {
507
+ if (options?.signal?.aborted || latestSpaceSlug.value !== spaceSlug) {
508
+ isStale = true;
509
+ return undefined;
510
+ }
511
+ if (isAxiosError(err) && err.response?.status === 404) {
512
+ const responseData = getErrorResponseData(err);
513
+ const candidateMessage = responseData?.message;
514
+ const normalizedMessage = typeof candidateMessage === 'string' && candidateMessage.trim().length > 0
515
+ ? candidateMessage
516
+ : 'Space not found';
517
+ const notFoundMessage = normalizedMessage.toLowerCase().includes('space not found')
518
+ ? normalizedMessage
519
+ : 'Space not found';
520
+ threadsLoadingState.setError(new Error(notFoundMessage));
521
+ error.value = notFoundMessage;
522
+ currentSpace.value = null;
523
+ return undefined;
524
+ }
525
+ logger.error('Failed to load threads:', err);
526
+ const errorMessage = getErrorMessage(err, 'Failed to load discussion threads');
527
+ threadsLoadingState.setError(new Error(errorMessage));
528
+ error.value = errorMessage;
529
+ throw err;
530
+ }
531
+ finally {
532
+ if (!isStale) {
533
+ threadsLoadingState.setLoading(false);
534
+ loading.value = false;
535
+ }
536
+ }
537
+ }
538
+ function isValidUUID(uuid) {
539
+ if (!uuid || typeof uuid !== 'string') {
540
+ return false;
541
+ }
542
+ const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
543
+ const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
544
+ const orderedUuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
545
+ return uuidV4Regex.test(uuid) || ulidRegex.test(uuid) || orderedUuidRegex.test(uuid);
546
+ }
547
+ async function loadThread(spaceSlug, threadId) {
548
+ latestThreadId.value = threadId;
549
+ cleanupRealtimeChannels();
550
+ loading.value = true;
551
+ error.value = null;
552
+ if (!threadId) {
553
+ error.value = 'Missing thread identifier';
554
+ loading.value = false;
555
+ return null;
556
+ }
557
+ if (!isValidUUID(threadId)) {
558
+ error.value = 'Invalid thread identifier format';
559
+ loading.value = false;
560
+ return null;
561
+ }
562
+ let isStale = false;
563
+ try {
564
+ const response = await client.get(`/v1/discussion/threads/${threadId}`);
565
+ if (latestThreadId.value !== threadId) {
566
+ isStale = true;
567
+ return null;
568
+ }
569
+ const responseData = toRecord(response.data);
570
+ const threadPayload = responseData?.data ?? response.data ?? null;
571
+ let threadData = null;
572
+ const threadRecord = toRecord(threadPayload);
573
+ if (threadRecord) {
574
+ const metaRecord = toRecord(threadRecord.meta);
575
+ const metaViews = metaRecord?.views;
576
+ const hasNumericMetaViews = typeof metaViews === 'number' && Number.isFinite(metaViews);
577
+ const legacyViewsCount = threadRecord.views_count;
578
+ const hasLegacyViewsCount = typeof legacyViewsCount === 'number' && Number.isFinite(legacyViewsCount);
579
+ if (!hasNumericMetaViews && hasLegacyViewsCount) {
580
+ const normalizedViews = Math.max(0, Math.floor(legacyViewsCount));
581
+ threadData = {
582
+ ...threadRecord,
583
+ meta: {
584
+ ...(metaRecord ?? {}),
585
+ views: normalizedViews,
586
+ },
587
+ };
588
+ }
589
+ else {
590
+ threadData = threadRecord;
591
+ }
592
+ }
593
+ if (threadData && !isValidUUID(threadData.id)) {
594
+ logger.error('Backend returned invalid thread ID:', threadData.id);
595
+ error.value = 'Invalid thread data received';
596
+ return null;
597
+ }
598
+ currentThread.value = threadData ? normalizeThreadReplyCount(threadData) : null;
599
+ if (threadData?.space_id) {
600
+ subscribeToSpaceRealtime(threadData.space_id);
601
+ }
602
+ if (threadData?.id) {
603
+ subscribeToThreadRealtime(threadData.id);
604
+ }
605
+ if (spaceSlug && (!currentSpace.value || currentSpace.value.slug !== spaceSlug)) {
606
+ currentSpace.value = spaces.value.find((space) => space.slug === spaceSlug) ?? null;
607
+ if (!spaces.value.length) {
608
+ await loadSpaces();
609
+ currentSpace.value = spaces.value.find((space) => space.slug === spaceSlug) ?? null;
610
+ }
611
+ }
612
+ return currentThread.value;
613
+ }
614
+ catch (err) {
615
+ if (latestThreadId.value !== threadId) {
616
+ isStale = true;
617
+ return null;
618
+ }
619
+ const errorResponseData = getErrorResponseData(err);
620
+ const isNotFoundError = isAxiosError(err) && err.response?.status === 404;
621
+ const isDeletedThreadError = isAxiosError(err)
622
+ && err.response?.status === 410
623
+ && errorResponseData?.code === 'THREAD_DELETED';
624
+ if (!isNotFoundError && !isDeletedThreadError) {
625
+ logger.error('Failed to load thread:', err);
626
+ }
627
+ if (isNotFoundError) {
628
+ error.value = 'Thread not found or has been deleted';
629
+ }
630
+ else if (isDeletedThreadError) {
631
+ error.value = 'Thread deleted';
632
+ }
633
+ else if (isAxiosError(err) && err.response?.status === 403) {
634
+ error.value = 'You do not have permission to view this thread';
635
+ }
636
+ else {
637
+ error.value = getErrorMessage(err, 'Failed to load thread');
638
+ }
639
+ throw err;
640
+ }
641
+ finally {
642
+ if (!isStale) {
643
+ loading.value = false;
644
+ }
645
+ }
646
+ }
647
+ async function loadReplies(threadId, cursor, sortBy = 'best') {
648
+ if (latestThreadId.value === null) {
649
+ latestThreadId.value = threadId;
650
+ }
651
+ loading.value = true;
652
+ error.value = null;
653
+ let isStale = false;
654
+ try {
655
+ const queryParts = [];
656
+ if (cursor) {
657
+ queryParts.push(`cursor=${encodeURIComponent(cursor)}`);
658
+ }
659
+ queryParts.push(`sort=${encodeURIComponent(sortBy)}`);
660
+ queryParts.push('format=tree');
661
+ queryParts.push('flatten=1');
662
+ const queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
663
+ const response = await client.get(`/v1/discussion/threads/${threadId}/replies${queryString}`);
664
+ if (latestThreadId.value !== threadId) {
665
+ isStale = true;
666
+ return undefined;
667
+ }
668
+ const responseData = toRecord(response.data);
669
+ const loadedReplyBatch = Array.isArray(responseData?.items)
670
+ ? responseData.items
671
+ : Array.isArray(responseData?.data)
672
+ ? responseData.data
673
+ : [];
674
+ let openingRepliesFilteredInBatch = 0;
675
+ const filterOpeningReplies = (batch) => {
676
+ const thread = currentThread.value;
677
+ if (!thread?.body || !thread.author_id) {
678
+ return batch;
679
+ }
680
+ const threadCreatedMs = new Date(thread.created_at).getTime();
681
+ return batch.filter((reply) => {
682
+ const replyCreatedMs = new Date(reply.created_at).getTime();
683
+ const isOpeningPost = reply.author_id === thread.author_id
684
+ && reply.body === thread.body
685
+ && Math.abs(replyCreatedMs - threadCreatedMs) < 5000;
686
+ if (isOpeningPost) {
687
+ openingRepliesFilteredInBatch += 1;
688
+ }
689
+ return !isOpeningPost;
690
+ });
691
+ };
692
+ const filteredReplyBatch = filterOpeningReplies(loadedReplyBatch);
693
+ if (cursor) {
694
+ replies.value = mergeUniqueById(replies.value, filteredReplyBatch);
695
+ }
696
+ else {
697
+ replies.value = filteredReplyBatch;
698
+ hiddenOpeningReplyCountByThread.set(threadId, openingRepliesFilteredInBatch);
699
+ subscribeToThreadRealtime(threadId);
700
+ }
701
+ if (cursor && openingRepliesFilteredInBatch > 0) {
702
+ const existingCount = hiddenOpeningReplyCountByThread.get(threadId) ?? 0;
703
+ hiddenOpeningReplyCountByThread.set(threadId, existingCount + openingRepliesFilteredInBatch);
704
+ }
705
+ const responseMeta = toRecord(responseData?.meta);
706
+ const responseNextCursor = typeof responseData?.next_cursor === 'string'
707
+ ? responseData.next_cursor
708
+ : typeof responseMeta?.next_cursor === 'string'
709
+ ? responseMeta.next_cursor
710
+ : null;
711
+ const replyTotalFromMeta = responseMeta?.total;
712
+ const hiddenOpeningReplyCount = hiddenOpeningReplyCountByThread.get(threadId) ?? 0;
713
+ const normalizedReplyCount = !responseNextCursor
714
+ ? replies.value.length
715
+ : typeof replyTotalFromMeta === 'number'
716
+ ? Math.max(0, Math.floor(replyTotalFromMeta) - hiddenOpeningReplyCount)
717
+ : replies.value.length;
718
+ syncThreadReplyCount(threadId, normalizedReplyCount);
719
+ repliesNextCursor.value = responseNextCursor;
720
+ return response.data;
721
+ }
722
+ catch (err) {
723
+ if (latestThreadId.value !== threadId) {
724
+ isStale = true;
725
+ return undefined;
726
+ }
727
+ logger.error('Failed to load replies:', err);
728
+ error.value = getErrorMessage(err, 'Failed to load replies');
729
+ throw err;
730
+ }
731
+ finally {
732
+ if (!isStale) {
733
+ loading.value = false;
734
+ }
735
+ }
736
+ }
737
+ async function createThread(spaceSlug, input) {
738
+ cleanupRealtimeChannels();
739
+ loading.value = true;
740
+ error.value = null;
741
+ try {
742
+ const response = await client.post(`/v1/discussion/spaces/${spaceSlug}/threads`, {
743
+ title: input.title,
744
+ body: input.body,
745
+ audience: input.audience || 'public',
746
+ ...(input.media_ids?.length ? { media_ids: input.media_ids } : {}),
747
+ ...(input.tags?.length ? { tags: input.tags } : {}),
748
+ });
749
+ const responseData = toRecord(response.data);
750
+ const newThread = (responseData?.data ?? null);
751
+ const normalizedNewThread = newThread ? normalizeThreadReplyCount(newThread) : null;
752
+ if (normalizedNewThread) {
753
+ threads.value.unshift(normalizedNewThread);
754
+ applyThreadListSorting();
755
+ }
756
+ if (normalizedNewThread?.space_id) {
757
+ subscribeToSpaceRealtime(normalizedNewThread.space_id);
758
+ }
759
+ return response.data;
760
+ }
761
+ catch (err) {
762
+ logger.error('Failed to create thread:', err);
763
+ error.value = getErrorMessage(err, 'Failed to create thread');
764
+ throw err;
765
+ }
766
+ finally {
767
+ loading.value = false;
768
+ }
769
+ }
770
+ async function loadTagCategories(query = '') {
771
+ loading.value = true;
772
+ error.value = null;
773
+ try {
774
+ const normalizedQuery = query.trim();
775
+ const encodedQuery = normalizedQuery.length > 0 ? `?q=${encodeURIComponent(normalizedQuery)}` : '';
776
+ const response = await client.get(`/v1/discussion/categories${encodedQuery}`);
777
+ const responseData = toRecord(response.data);
778
+ const items = Array.isArray(responseData?.items)
779
+ ? responseData.items
780
+ : Array.isArray(responseData?.data)
781
+ ? responseData.data
782
+ : [];
783
+ return items
784
+ .filter((item) => isRecord(item))
785
+ .map((item) => {
786
+ const id = typeof item.id === 'string' ? item.id : '';
787
+ const slug = typeof item.slug === 'string' ? item.slug : '';
788
+ const label = typeof item.label === 'string' ? item.label : '';
789
+ const threadCount = typeof item.thread_count === 'number' ? item.thread_count : undefined;
790
+ return {
791
+ id,
792
+ slug,
793
+ label,
794
+ ...(threadCount !== undefined ? { thread_count: threadCount } : {}),
795
+ };
796
+ })
797
+ .filter((item) => item.id !== '' && item.slug !== '' && item.label !== '');
798
+ }
799
+ catch (err) {
800
+ logger.error('Failed to load thread tag categories:', err);
801
+ error.value = getErrorMessage(err, 'Failed to load tag suggestions');
802
+ throw err;
803
+ }
804
+ finally {
805
+ loading.value = false;
806
+ }
807
+ }
808
+ function cleanupRealtimeChannels() {
809
+ const echo = getEcho();
810
+ if (!echo) {
811
+ activeSpaceChannel = null;
812
+ activeThreadChannel = null;
813
+ return;
814
+ }
815
+ if (activeSpaceChannel) {
816
+ echo.leave(activeSpaceChannel);
817
+ activeSpaceChannel = null;
818
+ }
819
+ if (activeThreadChannel) {
820
+ echo.leave(activeThreadChannel);
821
+ activeThreadChannel = null;
822
+ }
823
+ realtimeCountedReplyIds.clear();
824
+ }
825
+ function isSlowModeActiveError(err) {
826
+ if (isAxiosError(err) && err.response?.status === 429) {
827
+ return getErrorResponseData(err)?.code === 'SLOW_MODE_ACTIVE';
828
+ }
829
+ if (!err || typeof err !== 'object' || !('response' in err)) {
830
+ return false;
831
+ }
832
+ const response = err.response;
833
+ if (response?.status !== 429) {
834
+ return false;
835
+ }
836
+ const responseData = toRecord(response.data);
837
+ return responseData?.code === 'SLOW_MODE_ACTIVE';
838
+ }
839
+ function isNetworkError(err) {
840
+ if (!isAxiosError(err)) {
841
+ return false;
842
+ }
843
+ if (err.code === 'ERR_NETWORK') {
844
+ return true;
845
+ }
846
+ return err.message === 'Network Error' && !err.response;
847
+ }
848
+ async function createReply(threadId, input) {
849
+ pendingReplyCreations.set(threadId, (pendingReplyCreations.get(threadId) ?? 0) + 1);
850
+ try {
851
+ const response = await client.post(`/v1/discussion/threads/${threadId}/replies`, {
852
+ body: input.body,
853
+ parent_reply_id: input.parent_id,
854
+ ...(input.quoted_reply_id ? { quoted_reply_id: input.quoted_reply_id } : {}),
855
+ ...(input.media_ids?.length ? { media_ids: input.media_ids } : {}),
856
+ });
857
+ const responseData = toRecord(response.data);
858
+ const newReply = (responseData?.data ?? null);
859
+ if (newReply?.id) {
860
+ locallyCreatedReplyIds.add(newReply.id);
861
+ }
862
+ let didInsertReply = false;
863
+ if (newReply) {
864
+ const existingReplyIndex = replies.value.findIndex((reply) => reply.id === newReply.id);
865
+ if (existingReplyIndex !== -1) {
866
+ const existingReply = replies.value[existingReplyIndex];
867
+ if (existingReply) {
868
+ replies.value[existingReplyIndex] = {
869
+ ...existingReply,
870
+ ...newReply,
871
+ };
872
+ }
873
+ }
874
+ else if (input.parent_id) {
875
+ const parentIndex = replies.value.findIndex((reply) => reply.id === input.parent_id);
876
+ if (parentIndex !== -1) {
877
+ const parent = replies.value[parentIndex];
878
+ const parentDepth = parent?.depth ?? parent?.display_depth ?? 0;
879
+ newReply.depth = parentDepth + 1;
880
+ newReply.display_depth = parentDepth + 1;
881
+ newReply.parent_reply_id = input.parent_id;
882
+ let insertIndex = parentIndex + 1;
883
+ while (insertIndex < replies.value.length) {
884
+ const reply = replies.value[insertIndex];
885
+ const replyDepth = reply?.depth ?? reply?.display_depth ?? 0;
886
+ if (replyDepth <= parentDepth) {
887
+ break;
888
+ }
889
+ insertIndex += 1;
890
+ }
891
+ replies.value.splice(insertIndex, 0, newReply);
892
+ didInsertReply = true;
893
+ if (parent) {
894
+ parent.reply_count = (parent.reply_count ?? 0) + 1;
895
+ parent.children_count = (parent.children_count ?? 0) + 1;
896
+ }
897
+ }
898
+ else {
899
+ replies.value.push(newReply);
900
+ didInsertReply = true;
901
+ }
902
+ }
903
+ else {
904
+ newReply.depth = 0;
905
+ newReply.display_depth = 0;
906
+ replies.value.push(newReply);
907
+ didInsertReply = true;
908
+ }
909
+ }
910
+ if (didInsertReply && currentThread.value?.id === threadId) {
911
+ currentThread.value.reply_count += 1;
912
+ }
913
+ const thread = threads.value.find((candidate) => candidate.id === threadId);
914
+ if (thread) {
915
+ if (didInsertReply) {
916
+ thread.reply_count += 1;
917
+ }
918
+ else if (currentThread.value?.id === threadId) {
919
+ thread.reply_count = Math.max(thread.reply_count, currentThread.value.reply_count);
920
+ }
921
+ thread.last_activity_at = new Date().toISOString();
922
+ }
923
+ subscribeToThreadRealtime(threadId);
924
+ return response.data;
925
+ }
926
+ catch (err) {
927
+ if (!isSlowModeActiveError(err) && !isNetworkError(err)) {
928
+ logger.error('Failed to create reply:', err);
929
+ }
930
+ throw err;
931
+ }
932
+ finally {
933
+ const remaining = (pendingReplyCreations.get(threadId) ?? 1) - 1;
934
+ if (remaining <= 0) {
935
+ pendingReplyCreations.delete(threadId);
936
+ }
937
+ else {
938
+ pendingReplyCreations.set(threadId, remaining);
939
+ }
940
+ }
941
+ }
942
+ function subscribeToSpaceRealtime(spaceId) {
943
+ const echo = getEcho();
944
+ if (!echo) {
945
+ return;
946
+ }
947
+ const channelName = `discussions.space.${spaceId}`;
948
+ if (activeSpaceChannel === channelName) {
949
+ return;
950
+ }
951
+ if (activeSpaceChannel) {
952
+ echo.leave(activeSpaceChannel);
953
+ }
954
+ activeSpaceChannel = channelName;
955
+ echo.private(channelName)
956
+ .listen('.Discussion.ThreadCreated', (payload) => {
957
+ handleRealtimeThreadCreated(payload);
958
+ })
959
+ .listen('.Discussion.ReplyCreated', (payload) => {
960
+ handleRealtimeReplySummary(payload);
961
+ })
962
+ .listen('.Discussion.SpaceMembershipUpdated', (payload) => {
963
+ handleRealtimeSpaceMembershipUpdated(payload);
964
+ });
965
+ }
966
+ function subscribeToThreadRealtime(threadId) {
967
+ const echo = getEcho();
968
+ if (!echo) {
969
+ return;
970
+ }
971
+ const channelName = `discussions.thread.${threadId}`;
972
+ if (activeThreadChannel === channelName) {
973
+ return;
974
+ }
975
+ if (activeThreadChannel) {
976
+ echo.leave(activeThreadChannel);
977
+ }
978
+ activeThreadChannel = channelName;
979
+ echo.private(channelName)
980
+ .listen('.Discussion.ReplyCreated', (payload) => {
981
+ handleRealtimeReplyDetail(payload);
982
+ })
983
+ .listen('.Discussion.ReplyReactionToggled', (payload) => {
984
+ handleRealtimeReplyReaction(payload);
985
+ });
986
+ }
987
+ function handleRealtimeThreadCreated(payload) {
988
+ if (!payload?.id) {
989
+ return;
990
+ }
991
+ if (currentSpace.value && payload.space_id !== currentSpace.value.id) {
992
+ return;
993
+ }
994
+ const exists = threads.value.some((thread) => thread.id === payload.id);
995
+ if (!exists) {
996
+ threads.value.unshift(payload);
997
+ }
998
+ else {
999
+ threads.value = threads.value.map((thread) => (thread.id === payload.id ? { ...thread, ...payload } : thread));
1000
+ }
1001
+ applyThreadListSorting();
1002
+ }
1003
+ function handleRealtimeReplySummary(payload) {
1004
+ if (!payload?.thread_id) {
1005
+ return;
1006
+ }
1007
+ const isLocalReply = payload.id ? locallyCreatedReplyIds.has(payload.id) : false;
1008
+ if (isLocalReply) {
1009
+ locallyCreatedReplyIds.delete(payload.id);
1010
+ return;
1011
+ }
1012
+ const pendingCount = pendingReplyCreations.get(payload.thread_id) ?? 0;
1013
+ if (pendingCount > 0 && payload.id && payload.author_id) {
1014
+ if (payload.author_id === getCurrentUserId()) {
1015
+ locallyCreatedReplyIds.add(payload.id);
1016
+ return;
1017
+ }
1018
+ }
1019
+ const thread = threads.value.find((candidate) => candidate.id === payload.thread_id);
1020
+ const currentThreadForReply = currentThread.value?.id === payload.thread_id
1021
+ ? currentThread.value
1022
+ : null;
1023
+ const hasExistingProjection = Boolean(thread || currentThreadForReply);
1024
+ if (payload.is_initial_reply === true && hasExistingProjection) {
1025
+ const activityAt = payload.created_at ?? new Date().toISOString();
1026
+ if (thread) {
1027
+ thread.last_activity_at = activityAt;
1028
+ }
1029
+ if (currentThreadForReply) {
1030
+ currentThreadForReply.last_activity_at = activityAt;
1031
+ }
1032
+ return;
1033
+ }
1034
+ if (thread) {
1035
+ thread.reply_count = (thread.reply_count || 0) + 1;
1036
+ thread.last_activity_at = payload.created_at ?? new Date().toISOString();
1037
+ }
1038
+ if (currentThreadForReply) {
1039
+ if (payload.id && !realtimeCountedReplyIds.has(payload.id)) {
1040
+ realtimeCountedReplyIds.add(payload.id);
1041
+ currentThreadForReply.reply_count = (currentThreadForReply.reply_count || 0) + 1;
1042
+ }
1043
+ currentThreadForReply.last_activity_at = payload.created_at ?? new Date().toISOString();
1044
+ }
1045
+ }
1046
+ function handleRealtimeReplyDetail(payload) {
1047
+ if (!payload?.id || !payload.thread_id) {
1048
+ return;
1049
+ }
1050
+ if (currentThread.value?.id !== payload.thread_id) {
1051
+ return;
1052
+ }
1053
+ if (payload.is_initial_reply === true) {
1054
+ if (currentThread.value) {
1055
+ currentThread.value.last_activity_at = payload.created_at ?? new Date().toISOString();
1056
+ }
1057
+ return;
1058
+ }
1059
+ const normalizedPayload = (() => {
1060
+ const normalized = { ...payload };
1061
+ const quotedReplyId = normalized.quoted_reply_id;
1062
+ const hasQuotedReplyId = typeof quotedReplyId === 'string' && quotedReplyId.trim().length > 0;
1063
+ if (!hasQuotedReplyId) {
1064
+ normalized.quoted_reply_id = null;
1065
+ normalized.quoted_reply = null;
1066
+ normalized.quote_data = null;
1067
+ }
1068
+ return normalized;
1069
+ })();
1070
+ const already = replies.value.some((reply) => reply.id === payload.id);
1071
+ if (!already) {
1072
+ replies.value.push(normalizedPayload);
1073
+ if (currentThread.value && payload.id && !realtimeCountedReplyIds.has(payload.id)) {
1074
+ realtimeCountedReplyIds.add(payload.id);
1075
+ currentThread.value.reply_count = (currentThread.value.reply_count || 0) + 1;
1076
+ currentThread.value.last_activity_at = payload.created_at ?? new Date().toISOString();
1077
+ }
1078
+ }
1079
+ }
1080
+ function handleRealtimeReplyReaction(payload) {
1081
+ if (!payload?.reply_id || !payload.thread_id || !payload.user_id) {
1082
+ return;
1083
+ }
1084
+ if (currentThread.value?.id !== payload.thread_id) {
1085
+ return;
1086
+ }
1087
+ const action = payload.action === 'added' || payload.action === 'removed'
1088
+ ? payload.action
1089
+ : null;
1090
+ const kind = payload.kind === 'like' || payload.kind === 'dislike'
1091
+ ? payload.kind
1092
+ : null;
1093
+ if (!action || !kind) {
1094
+ return;
1095
+ }
1096
+ const reply = replies.value.find((item) => item.id === payload.reply_id);
1097
+ if (!reply) {
1098
+ return;
1099
+ }
1100
+ const currentUserId = getCurrentUserId();
1101
+ const localPendingCount = pendingLocalReactionToggles.get(payload.reply_id) ?? 0;
1102
+ const localEchoMarker = pendingLocalReactionEchoes.get(payload.reply_id);
1103
+ const now = Date.now();
1104
+ if (localEchoMarker && localEchoMarker.expiresAt <= now) {
1105
+ pendingLocalReactionEchoes.delete(payload.reply_id);
1106
+ }
1107
+ const hasMatchingEchoMarker = localEchoMarker !== undefined
1108
+ && localEchoMarker.expiresAt > now
1109
+ && localEchoMarker.kind === kind
1110
+ && localEchoMarker.action === action;
1111
+ const isLocalEcho = localPendingCount > 0
1112
+ && currentUserId !== null
1113
+ && payload.user_id === currentUserId;
1114
+ if (currentUserId !== null && payload.user_id === currentUserId && (isLocalEcho || hasMatchingEchoMarker)) {
1115
+ if (hasMatchingEchoMarker) {
1116
+ pendingLocalReactionEchoes.delete(payload.reply_id);
1117
+ }
1118
+ return;
1119
+ }
1120
+ if (action === 'added') {
1121
+ if (kind === 'like') {
1122
+ reply.likes_count = (reply.likes_count ?? 0) + 1;
1123
+ }
1124
+ else {
1125
+ reply.dislikes_count = (reply.dislikes_count ?? 0) + 1;
1126
+ }
1127
+ }
1128
+ else if (kind === 'like') {
1129
+ reply.likes_count = Math.max(0, (reply.likes_count ?? 0) - 1);
1130
+ }
1131
+ else {
1132
+ reply.dislikes_count = Math.max(0, (reply.dislikes_count ?? 0) - 1);
1133
+ }
1134
+ if (payload.user_id !== currentUserId) {
1135
+ return;
1136
+ }
1137
+ const currentKind = resolveReactionKind(reply.user_reaction);
1138
+ if (action === 'added') {
1139
+ reply.user_reaction = kind;
1140
+ return;
1141
+ }
1142
+ if (currentKind === kind) {
1143
+ reply.user_reaction = null;
1144
+ }
1145
+ }
1146
+ function handleRealtimeSpaceMembershipUpdated(payload) {
1147
+ const spaceId = typeof payload?.space_id === 'string' && payload.space_id.length > 0
1148
+ ? payload.space_id
1149
+ : null;
1150
+ const memberCount = typeof payload?.member_count === 'number'
1151
+ ? Math.max(0, Math.floor(payload.member_count))
1152
+ : null;
1153
+ if (!spaceId || memberCount === null) {
1154
+ return;
1155
+ }
1156
+ const existingMembership = getSpaceMembership(spaceId);
1157
+ const actorId = typeof payload?.actor_id === 'string' && payload.actor_id.length > 0
1158
+ ? payload.actor_id
1159
+ : null;
1160
+ const payloadIsMember = typeof payload?.is_member === 'boolean'
1161
+ ? payload.is_member
1162
+ : null;
1163
+ const isCurrentUserActor = actorId !== null && actorId === getCurrentUserId();
1164
+ if (!existingMembership && !isCurrentUserActor) {
1165
+ updateSpaceMemberCount(spaceId, memberCount);
1166
+ return;
1167
+ }
1168
+ const resolvedIsMember = payloadIsMember !== null && isCurrentUserActor
1169
+ ? payloadIsMember
1170
+ : existingMembership?.is_member ?? false;
1171
+ setSpaceMembership(spaceId, {
1172
+ space_id: spaceId,
1173
+ is_member: resolvedIsMember,
1174
+ member_count: memberCount,
1175
+ });
1176
+ }
1177
+ async function subscribeToThread(threadId) {
1178
+ try {
1179
+ const response = await client.post(`/v1/discussion/threads/${threadId}/subscribe`);
1180
+ return response.data;
1181
+ }
1182
+ catch (err) {
1183
+ logger.error('Failed to subscribe:', err);
1184
+ throw err;
1185
+ }
1186
+ }
1187
+ async function unsubscribeFromThread(threadId) {
1188
+ try {
1189
+ const response = await client.delete(`/v1/discussion/threads/${threadId}/subscribe`);
1190
+ return response.data;
1191
+ }
1192
+ catch (err) {
1193
+ logger.error('Failed to unsubscribe:', err);
1194
+ throw err;
1195
+ }
1196
+ }
1197
+ async function searchThreads(query, spaceSlug) {
1198
+ loading.value = true;
1199
+ error.value = null;
1200
+ try {
1201
+ const params = new URLSearchParams({ q: query });
1202
+ if (spaceSlug) {
1203
+ params.append('space', spaceSlug);
1204
+ }
1205
+ const response = await client.get(`/v1/discussion/search/threads?${params.toString()}`);
1206
+ return response.data;
1207
+ }
1208
+ catch (err) {
1209
+ logger.error('Failed to search threads:', err);
1210
+ error.value = getErrorMessage(err, 'Failed to search');
1211
+ throw err;
1212
+ }
1213
+ finally {
1214
+ loading.value = false;
1215
+ }
1216
+ }
1217
+ async function searchThreadsInSpace(query, spaceId) {
1218
+ const normalizedQuery = query.trim();
1219
+ if (normalizedQuery.length < 2) {
1220
+ return [];
1221
+ }
1222
+ loading.value = true;
1223
+ error.value = null;
1224
+ try {
1225
+ const response = await client.post('/v1/discussion/search', {
1226
+ query: normalizedQuery,
1227
+ space_id: spaceId,
1228
+ sort_by: 'relevance',
1229
+ limit: 50,
1230
+ });
1231
+ const rows = extractThreadSearchRows(response.data);
1232
+ if (rows.length > 0) {
1233
+ return rows.map((row) => mapSearchRowToThread(row, spaceId));
1234
+ }
1235
+ return filterLoadedThreadsByQuery(normalizedQuery, spaceId);
1236
+ }
1237
+ catch (err) {
1238
+ logger.error('Failed to search threads in space:', err);
1239
+ error.value = isAxiosError(err)
1240
+ ? err.response?.data?.message ?? 'Failed to search threads'
1241
+ : 'Failed to search threads';
1242
+ throw err;
1243
+ }
1244
+ finally {
1245
+ loading.value = false;
1246
+ }
1247
+ }
1248
+ async function searchThreadsGlobally(query) {
1249
+ const normalizedQuery = query.trim();
1250
+ if (normalizedQuery.length < 2) {
1251
+ return [];
1252
+ }
1253
+ loading.value = true;
1254
+ error.value = null;
1255
+ try {
1256
+ const response = await client.post('/v1/discussion/search', {
1257
+ query: normalizedQuery,
1258
+ sort_by: 'relevance',
1259
+ limit: 50,
1260
+ });
1261
+ return extractThreadSearchRows(response.data).map((row) => mapSearchRowToThread(row));
1262
+ }
1263
+ catch (err) {
1264
+ logger.error('Failed to search threads globally:', err);
1265
+ error.value = isAxiosError(err)
1266
+ ? err.response?.data?.message ?? 'Failed to search threads'
1267
+ : 'Failed to search threads';
1268
+ throw err;
1269
+ }
1270
+ finally {
1271
+ loading.value = false;
1272
+ }
1273
+ }
1274
+ function mapSearchRowToThread(row, fallbackSpaceId) {
1275
+ const nowIso = new Date().toISOString();
1276
+ const thread = {
1277
+ id: row.id,
1278
+ space_id: row.space_id ?? fallbackSpaceId ?? '',
1279
+ author_id: row.author_id ?? '',
1280
+ title: row.title,
1281
+ audience: 'public',
1282
+ status: row.status ?? 'open',
1283
+ is_pinned: Boolean(row.is_pinned),
1284
+ reply_count: row.replies_count ?? 0,
1285
+ last_activity_at: row.updated_at ?? row.created_at ?? nowIso,
1286
+ created_at: row.created_at ?? nowIso,
1287
+ meta: {
1288
+ views: row.views_count ?? 0,
1289
+ },
1290
+ author: {
1291
+ id: row.author_id ?? '',
1292
+ name: row.author_name ?? 'Anonymous',
1293
+ handle: row.author_handle ?? '',
1294
+ },
1295
+ };
1296
+ if (typeof row.slug === 'string') {
1297
+ thread.slug = row.slug;
1298
+ }
1299
+ return thread;
1300
+ }
1301
+ function extractThreadSearchRows(responseBody) {
1302
+ const nestedResults = responseBody.data?.results;
1303
+ if (Array.isArray(nestedResults)) {
1304
+ return nestedResults;
1305
+ }
1306
+ const topLevelResults = responseBody.results;
1307
+ if (Array.isArray(topLevelResults)) {
1308
+ return topLevelResults;
1309
+ }
1310
+ return [];
1311
+ }
1312
+ function filterLoadedThreadsByQuery(query, spaceId) {
1313
+ const normalizedTerms = query
1314
+ .toLowerCase()
1315
+ .split(/\s+/)
1316
+ .map((term) => term.trim())
1317
+ .filter((term) => term.length > 0);
1318
+ if (normalizedTerms.length === 0) {
1319
+ return [];
1320
+ }
1321
+ return threads.value.filter((thread) => {
1322
+ if (thread.space_id !== spaceId) {
1323
+ return false;
1324
+ }
1325
+ const title = thread.title.toLowerCase();
1326
+ const body = thread.body?.toLowerCase() ?? '';
1327
+ return normalizedTerms.every((term) => title.includes(term) || body.includes(term));
1328
+ });
1329
+ }
1330
+ function featuredSpaces() {
1331
+ return spaces.value.filter((space) => space.meta?.is_featured);
1332
+ }
1333
+ async function setThreadPinned(threadId, pinned) {
1334
+ loading.value = true;
1335
+ error.value = null;
1336
+ try {
1337
+ await client.post(`/v1/discussion/threads/${threadId}/pin`, { pinned });
1338
+ if (currentThread.value?.id === threadId && currentThread.value) {
1339
+ currentThread.value = { ...currentThread.value, is_pinned: pinned };
1340
+ }
1341
+ const thread = threads.value.find((candidate) => candidate.id === threadId);
1342
+ if (thread) {
1343
+ thread.is_pinned = pinned;
1344
+ }
1345
+ applyThreadListSorting();
1346
+ }
1347
+ catch (err) {
1348
+ logger.error('Failed to update pinned status:', err);
1349
+ error.value = getErrorMessage(err, 'Failed to update pinned status');
1350
+ throw err;
1351
+ }
1352
+ finally {
1353
+ loading.value = false;
1354
+ }
1355
+ }
1356
+ async function setThreadLocked(threadId, locked) {
1357
+ loading.value = true;
1358
+ error.value = null;
1359
+ try {
1360
+ await client.post(`/v1/discussion/threads/${threadId}/lock`, { locked });
1361
+ const nextStatus = locked ? 'locked' : 'open';
1362
+ if (currentThread.value?.id === threadId && currentThread.value) {
1363
+ currentThread.value = { ...currentThread.value, status: nextStatus };
1364
+ }
1365
+ const thread = threads.value.find((candidate) => candidate.id === threadId);
1366
+ if (thread) {
1367
+ thread.status = nextStatus;
1368
+ }
1369
+ }
1370
+ catch (err) {
1371
+ logger.error('Failed to update locked status:', err);
1372
+ error.value = getErrorMessage(err, 'Failed to update locked status');
1373
+ throw err;
1374
+ }
1375
+ finally {
1376
+ loading.value = false;
1377
+ }
1378
+ }
1379
+ async function moveThread(threadId, toSpaceSlug) {
1380
+ loading.value = true;
1381
+ error.value = null;
1382
+ try {
1383
+ const response = await client.post(`/v1/discussion/threads/${threadId}/move`, {
1384
+ to_space_slug: toSpaceSlug,
1385
+ });
1386
+ const responseData = toRecord(response.data);
1387
+ const movedThread = (responseData?.data ?? null);
1388
+ const destinationSpace = spaces.value.find((space) => space.slug === toSpaceSlug);
1389
+ if (currentThread.value?.id === threadId && currentThread.value) {
1390
+ currentThread.value = {
1391
+ ...currentThread.value,
1392
+ ...(movedThread ?? {}),
1393
+ ...(destinationSpace ? { space_id: destinationSpace.id } : {}),
1394
+ };
1395
+ }
1396
+ const currentSpaceSlug = currentSpace.value?.slug ?? null;
1397
+ if (currentSpaceSlug && currentSpaceSlug !== toSpaceSlug) {
1398
+ threads.value = threads.value.filter((thread) => thread.id !== threadId);
1399
+ }
1400
+ else {
1401
+ const threadIndex = threads.value.findIndex((thread) => thread.id === threadId);
1402
+ if (threadIndex > -1) {
1403
+ const existingThread = threads.value[threadIndex];
1404
+ if (!existingThread) {
1405
+ return;
1406
+ }
1407
+ threads.value[threadIndex] = {
1408
+ ...existingThread,
1409
+ ...(movedThread ?? {}),
1410
+ ...(destinationSpace ? { space_id: destinationSpace.id } : {}),
1411
+ };
1412
+ }
1413
+ }
1414
+ applyThreadListSorting();
1415
+ }
1416
+ catch (err) {
1417
+ logger.error('Failed to move thread:', err);
1418
+ error.value = getErrorMessage(err, 'Failed to move thread');
1419
+ throw err;
1420
+ }
1421
+ finally {
1422
+ loading.value = false;
1423
+ }
1424
+ }
1425
+ async function updateThread(threadId, updates) {
1426
+ loading.value = true;
1427
+ error.value = null;
1428
+ try {
1429
+ const response = await client.patch(`/v1/discussion/threads/${threadId}`, {
1430
+ ...updates,
1431
+ ...(updates.media_ids ? { media_ids: updates.media_ids } : {}),
1432
+ });
1433
+ const responseData = toRecord(response.data);
1434
+ const updatedThread = toRecord(responseData?.data);
1435
+ if (currentThread.value?.id === threadId && updatedThread) {
1436
+ currentThread.value = { ...currentThread.value, ...updatedThread };
1437
+ }
1438
+ const threadIndex = threads.value.findIndex((thread) => thread.id === threadId);
1439
+ if (threadIndex > -1 && updatedThread) {
1440
+ const existingThread = threads.value[threadIndex];
1441
+ if (existingThread) {
1442
+ threads.value[threadIndex] = {
1443
+ ...existingThread,
1444
+ ...updatedThread,
1445
+ };
1446
+ }
1447
+ }
1448
+ return response.data;
1449
+ }
1450
+ catch (err) {
1451
+ logger.error('Failed to update thread:', err);
1452
+ error.value = getErrorMessage(err, 'Failed to update thread');
1453
+ throw err;
1454
+ }
1455
+ finally {
1456
+ loading.value = false;
1457
+ }
1458
+ }
1459
+ async function deleteThread(threadId) {
1460
+ loading.value = true;
1461
+ error.value = null;
1462
+ try {
1463
+ const response = await client.delete(`/v1/discussion/threads/${threadId}`);
1464
+ threads.value = threads.value.filter((thread) => thread.id !== threadId);
1465
+ if (currentThread.value?.id === threadId) {
1466
+ currentThread.value = null;
1467
+ }
1468
+ return response.data;
1469
+ }
1470
+ catch (err) {
1471
+ logger.error('Failed to delete thread:', err);
1472
+ error.value = getErrorMessage(err, 'Failed to delete thread');
1473
+ throw err;
1474
+ }
1475
+ finally {
1476
+ loading.value = false;
1477
+ }
1478
+ }
1479
+ async function restoreThread(threadId) {
1480
+ loading.value = true;
1481
+ error.value = null;
1482
+ try {
1483
+ const response = await client.post(`/v1/discussion/threads/${threadId}/restore`);
1484
+ const responseData = toRecord(response.data);
1485
+ const restoredThread = (responseData?.data ?? null);
1486
+ if (restoredThread) {
1487
+ const threadIndex = threads.value.findIndex((thread) => thread.id === threadId);
1488
+ if (threadIndex > -1) {
1489
+ const existingThread = threads.value[threadIndex];
1490
+ if (existingThread) {
1491
+ threads.value[threadIndex] = {
1492
+ ...existingThread,
1493
+ ...restoredThread,
1494
+ deleted_at: null,
1495
+ };
1496
+ }
1497
+ }
1498
+ else {
1499
+ threads.value.unshift({
1500
+ ...restoredThread,
1501
+ deleted_at: null,
1502
+ });
1503
+ }
1504
+ applyThreadListSorting();
1505
+ currentThread.value = {
1506
+ ...restoredThread,
1507
+ deleted_at: null,
1508
+ };
1509
+ }
1510
+ if (!restoredThread) {
1511
+ throw new Error('Restore request succeeded without thread payload');
1512
+ }
1513
+ return restoredThread;
1514
+ }
1515
+ catch (err) {
1516
+ logger.error('Failed to restore thread:', err);
1517
+ error.value = getErrorMessage(err, 'Failed to restore thread');
1518
+ throw err;
1519
+ }
1520
+ finally {
1521
+ loading.value = false;
1522
+ }
1523
+ }
1524
+ async function deleteReply(replyId) {
1525
+ loading.value = true;
1526
+ error.value = null;
1527
+ const replyIndex = replies.value.findIndex((reply) => reply.id === replyId);
1528
+ const replyToRestore = replyIndex >= 0 ? replies.value[replyIndex] ?? null : null;
1529
+ const targetThreadId = replyToRestore?.thread_id ?? currentThread.value?.id ?? null;
1530
+ const threadListIndex = targetThreadId
1531
+ ? threads.value.findIndex((thread) => thread.id === targetThreadId)
1532
+ : -1;
1533
+ const previousCurrentThreadReplyCount = currentThread.value?.reply_count;
1534
+ const previousThreadReplyCount = threadListIndex >= 0
1535
+ ? threads.value[threadListIndex]?.reply_count
1536
+ : undefined;
1537
+ if (replyIndex >= 0) {
1538
+ replies.value.splice(replyIndex, 1);
1539
+ }
1540
+ if (currentThread.value && typeof currentThread.value.reply_count === 'number') {
1541
+ currentThread.value = {
1542
+ ...currentThread.value,
1543
+ reply_count: Math.max(0, currentThread.value.reply_count - 1),
1544
+ };
1545
+ }
1546
+ if (threadListIndex >= 0) {
1547
+ const thread = threads.value[threadListIndex];
1548
+ if (thread && typeof thread.reply_count === 'number') {
1549
+ threads.value[threadListIndex] = {
1550
+ ...thread,
1551
+ reply_count: Math.max(0, thread.reply_count - 1),
1552
+ };
1553
+ }
1554
+ }
1555
+ try {
1556
+ await client.delete(`/v1/discussion/replies/${replyId}`);
1557
+ }
1558
+ catch (err) {
1559
+ if (replyToRestore && replyIndex >= 0) {
1560
+ const boundedIndex = Math.min(replyIndex, replies.value.length);
1561
+ replies.value.splice(boundedIndex, 0, replyToRestore);
1562
+ }
1563
+ if (currentThread.value && typeof previousCurrentThreadReplyCount === 'number') {
1564
+ currentThread.value = {
1565
+ ...currentThread.value,
1566
+ reply_count: previousCurrentThreadReplyCount,
1567
+ };
1568
+ }
1569
+ if (threadListIndex >= 0 && typeof previousThreadReplyCount === 'number') {
1570
+ const thread = threads.value[threadListIndex];
1571
+ if (thread) {
1572
+ threads.value[threadListIndex] = {
1573
+ ...thread,
1574
+ reply_count: previousThreadReplyCount,
1575
+ };
1576
+ }
1577
+ }
1578
+ logger.error('Failed to delete reply:', err);
1579
+ error.value = getErrorMessage(err, 'Failed to delete reply');
1580
+ throw err;
1581
+ }
1582
+ finally {
1583
+ loading.value = false;
1584
+ }
1585
+ }
1586
+ async function updateReply(replyId, body) {
1587
+ const reply = replies.value.find((candidate) => candidate.id === replyId);
1588
+ const prevBody = reply?.body ?? '';
1589
+ if (reply) {
1590
+ reply.body = body;
1591
+ reply.updated_at = new Date().toISOString();
1592
+ }
1593
+ try {
1594
+ const response = await client.patch(`/v1/discussion/replies/${replyId}`, { body });
1595
+ const responseData = toRecord(response.data);
1596
+ const updated = (responseData?.data ?? null);
1597
+ if (!updated) {
1598
+ throw new Error('Reply update succeeded without a reply payload');
1599
+ }
1600
+ if (reply) {
1601
+ reply.body = updated.body;
1602
+ reply.updated_at = updated.updated_at;
1603
+ }
1604
+ return updated;
1605
+ }
1606
+ catch (err) {
1607
+ if (reply) {
1608
+ reply.body = prevBody;
1609
+ }
1610
+ logger.error('Failed to update reply:', err);
1611
+ throw err;
1612
+ }
1613
+ }
1614
+ function resolveReactionKind(reaction) {
1615
+ if (!reaction) {
1616
+ return null;
1617
+ }
1618
+ if (typeof reaction === 'string') {
1619
+ return reaction;
1620
+ }
1621
+ if (typeof reaction === 'object' && 'kind' in reaction && typeof reaction.kind === 'string') {
1622
+ return reaction.kind;
1623
+ }
1624
+ return null;
1625
+ }
1626
+ async function toggleReplyReaction(replyId, kind) {
1627
+ const reply = replies.value.find((candidate) => candidate.id === replyId);
1628
+ if (!reply) {
1629
+ return;
1630
+ }
1631
+ pendingLocalReactionToggles.set(replyId, (pendingLocalReactionToggles.get(replyId) ?? 0) + 1);
1632
+ const prevReactionRaw = reply.user_reaction ?? null;
1633
+ const prevLikes = reply.likes_count ?? 0;
1634
+ const prevDislikes = reply.dislikes_count ?? 0;
1635
+ const prevKind = resolveReactionKind(prevReactionRaw);
1636
+ if (kind === 'none') {
1637
+ if (prevKind === 'like') {
1638
+ reply.likes_count = Math.max(0, prevLikes - 1);
1639
+ }
1640
+ else if (prevKind === 'dislike') {
1641
+ reply.dislikes_count = Math.max(0, prevDislikes - 1);
1642
+ }
1643
+ reply.user_reaction = null;
1644
+ }
1645
+ else {
1646
+ if (prevKind === 'like') {
1647
+ reply.likes_count = Math.max(0, prevLikes - 1);
1648
+ }
1649
+ else if (prevKind === 'dislike') {
1650
+ reply.dislikes_count = Math.max(0, prevDislikes - 1);
1651
+ }
1652
+ if (kind === 'like') {
1653
+ reply.likes_count = (reply.likes_count ?? 0) + 1;
1654
+ }
1655
+ else if (kind === 'dislike') {
1656
+ reply.dislikes_count = (reply.dislikes_count ?? 0) + 1;
1657
+ }
1658
+ reply.user_reaction = kind;
1659
+ }
1660
+ const apiKind = kind === 'none' ? prevKind : kind;
1661
+ if (apiKind === 'like' || apiKind === 'dislike') {
1662
+ const echoAction = kind === 'none' ? 'removed' : 'added';
1663
+ pendingLocalReactionEchoes.set(replyId, {
1664
+ kind: apiKind,
1665
+ action: echoAction,
1666
+ expiresAt: Date.now() + 5000,
1667
+ });
1668
+ }
1669
+ else {
1670
+ pendingLocalReactionEchoes.delete(replyId);
1671
+ }
1672
+ try {
1673
+ await client.post('/v1/reactions/toggle', {
1674
+ target_type: 'reply',
1675
+ target_id: replyId,
1676
+ kind: apiKind,
1677
+ });
1678
+ }
1679
+ catch (err) {
1680
+ reply.likes_count = prevLikes;
1681
+ reply.dislikes_count = prevDislikes;
1682
+ reply.user_reaction = prevReactionRaw;
1683
+ pendingLocalReactionEchoes.delete(replyId);
1684
+ logger.error('Failed to toggle reaction:', err);
1685
+ }
1686
+ finally {
1687
+ const remaining = (pendingLocalReactionToggles.get(replyId) ?? 1) - 1;
1688
+ if (remaining <= 0) {
1689
+ pendingLocalReactionToggles.delete(replyId);
1690
+ }
1691
+ else {
1692
+ pendingLocalReactionToggles.set(replyId, remaining);
1693
+ }
1694
+ }
1695
+ }
1696
+ function getCategories() {
1697
+ const categories = new Map();
1698
+ spaces.value.forEach((space) => {
1699
+ const category = space.meta?.category || space.name.split(' ')[0] || 'Uncategorized';
1700
+ const existingCategory = categories.get(category);
1701
+ if (existingCategory) {
1702
+ existingCategory.count += 1;
1703
+ return;
1704
+ }
1705
+ categories.set(category, {
1706
+ name: category,
1707
+ count: 1,
1708
+ ...(space.meta?.icon ? { icon: space.meta.icon } : {}),
1709
+ ...(space.meta?.color ? { color: space.meta.color } : {}),
1710
+ });
1711
+ });
1712
+ return Array.from(categories.values());
1713
+ }
1714
+ async function saveDraft(type, id, content, options) {
1715
+ try {
1716
+ const response = await client.post('/v1/editor/drafts/save', {
1717
+ type,
1718
+ id,
1719
+ title: options?.title,
1720
+ content,
1721
+ metadata: options?.metadata,
1722
+ format: options?.format ?? 'markdown',
1723
+ });
1724
+ return response.data;
1725
+ }
1726
+ catch (draftError) {
1727
+ logger.error('Failed to save draft:', draftError);
1728
+ throw draftError;
1729
+ }
1730
+ }
1731
+ async function getDraft(type, id) {
1732
+ try {
1733
+ const response = await client.get('/v1/editor/drafts/check', {
1734
+ params: { type, id },
1735
+ });
1736
+ const responseData = toRecord(response.data);
1737
+ return responseData?.data ?? null;
1738
+ }
1739
+ catch (draftError) {
1740
+ if (isAxiosError(draftError) && draftError.response?.status === 404) {
1741
+ return null;
1742
+ }
1743
+ logger.error('Failed to get draft:', draftError);
1744
+ return null;
1745
+ }
1746
+ }
1747
+ async function clearDraft(type, id) {
1748
+ try {
1749
+ await client.delete('/v1/editor/drafts/delete', {
1750
+ params: { type, id },
1751
+ });
1752
+ }
1753
+ catch (draftError) {
1754
+ if (isAxiosError(draftError) && draftError.response?.status === 404) {
1755
+ return;
1756
+ }
1757
+ logger.error('Failed to clear draft:', draftError);
1758
+ }
1759
+ }
1760
+ function generateThreadDraftId(spaceSlug) {
1761
+ return `${spaceSlug}-new`;
1762
+ }
1763
+ function generateReplyDraftId(threadId) {
1764
+ return `${threadId}-reply`;
1765
+ }
1766
+ function setError(message) {
1767
+ error.value = message;
1768
+ }
1769
+ const stopListeningToEchoReconnect = onEchoReconnected(() => {
1770
+ const savedSpace = activeSpaceChannel;
1771
+ const savedThread = activeThreadChannel;
1772
+ activeSpaceChannel = null;
1773
+ activeThreadChannel = null;
1774
+ if (savedSpace) {
1775
+ const spaceId = savedSpace.replace('discussions.space.', '');
1776
+ subscribeToSpaceRealtime(spaceId);
1777
+ }
1778
+ if (savedThread) {
1779
+ const threadId = savedThread.replace('discussions.thread.', '');
1780
+ subscribeToThreadRealtime(threadId);
1781
+ }
1782
+ });
1783
+ onScopeDispose(() => {
1784
+ if (typeof stopListeningToEchoReconnect === 'function') {
1785
+ stopListeningToEchoReconnect();
1786
+ }
1787
+ cleanupRealtimeChannels();
1788
+ });
1789
+ async function fetchQuote(replyId) {
1790
+ const reply = replies.value.find((candidate) => candidate.id === replyId);
1791
+ const selectedText = reply?.body ?? '';
1792
+ const response = await client.post(`/v1/discussion/replies/${replyId}/quote`, {
1793
+ selected_text: selectedText.slice(0, 1000),
1794
+ });
1795
+ const responseData = toRecord(response.data);
1796
+ return (responseData?.data ?? {});
1797
+ }
1798
+ return {
1799
+ spaces,
1800
+ spaceTree,
1801
+ currentSpace,
1802
+ spaceMemberships,
1803
+ spaceMembershipLoading,
1804
+ cleanupRealtimeChannels,
1805
+ threads,
1806
+ currentThread,
1807
+ replies,
1808
+ loading,
1809
+ error,
1810
+ nextCursor,
1811
+ repliesNextCursor,
1812
+ spacesLoadingState,
1813
+ threadsLoadingState,
1814
+ repliesLoadingState,
1815
+ loadSpaces,
1816
+ loadSpaceDetail,
1817
+ loadSpaceMembership,
1818
+ joinSpace,
1819
+ leaveSpace,
1820
+ getSpaceMembership,
1821
+ flattenTree,
1822
+ rootSpaces,
1823
+ leafSpaces,
1824
+ loadThreads,
1825
+ loadThread,
1826
+ loadReplies,
1827
+ createThread,
1828
+ loadTagCategories,
1829
+ createReply,
1830
+ fetchQuote,
1831
+ subscribeToThread,
1832
+ unsubscribeFromThread,
1833
+ searchThreads,
1834
+ searchThreadsInSpace,
1835
+ searchThreadsGlobally,
1836
+ featuredSpaces,
1837
+ getCategories,
1838
+ setThreadPinned,
1839
+ setThreadLocked,
1840
+ moveThread,
1841
+ updateThread,
1842
+ deleteThread,
1843
+ restoreThread,
1844
+ deleteReply,
1845
+ updateReply,
1846
+ toggleReplyReaction,
1847
+ draftTypes,
1848
+ saveDraft,
1849
+ getDraft,
1850
+ clearDraft,
1851
+ generateThreadDraftId,
1852
+ generateReplyDraftId,
1853
+ setError,
1854
+ };
1855
+ });
1856
+ }
1857
+ //# sourceMappingURL=discussion.js.map