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