@codingfactory/socialkit-vue 0.7.23 → 0.7.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/services/circles.d.ts +7 -0
  5. package/dist/services/circles.d.ts.map +1 -1
  6. package/dist/services/circles.js +34 -5
  7. package/dist/services/circles.js.map +1 -1
  8. package/dist/stores/__tests__/discussion.spec.d.ts +2 -0
  9. package/dist/stores/__tests__/discussion.spec.d.ts.map +1 -0
  10. package/dist/stores/__tests__/discussion.spec.js +768 -0
  11. package/dist/stores/__tests__/discussion.spec.js.map +1 -0
  12. package/dist/stores/circles.d.ts +144 -0
  13. package/dist/stores/circles.d.ts.map +1 -1
  14. package/dist/stores/circles.js +7 -1
  15. package/dist/stores/circles.js.map +1 -1
  16. package/dist/stores/content.d.ts.map +1 -1
  17. package/dist/stores/content.js +6 -3
  18. package/dist/stores/content.js.map +1 -1
  19. package/dist/stores/discussion.d.ts +714 -15
  20. package/dist/stores/discussion.d.ts.map +1 -1
  21. package/dist/stores/discussion.js +272 -65
  22. package/dist/stores/discussion.js.map +1 -1
  23. package/dist/types/content.d.ts +3 -2
  24. package/dist/types/content.d.ts.map +1 -1
  25. package/dist/types/content.js +2 -1
  26. package/dist/types/content.js.map +1 -1
  27. package/dist/types/discussion.d.ts +38 -0
  28. package/dist/types/discussion.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/index.ts +4 -0
  31. package/src/services/circles.ts +45 -5
  32. package/src/stores/__tests__/discussion.spec.ts +945 -0
  33. package/src/stores/circles.ts +7 -1
  34. package/src/stores/content.ts +6 -3
  35. package/src/stores/discussion.ts +333 -76
  36. package/src/types/content.ts +3 -2
  37. package/src/types/discussion.ts +43 -0
@@ -6,16 +6,21 @@ import { isAxiosError } from 'axios'
6
6
  import { defineStore } from 'pinia'
7
7
  import { onScopeDispose, ref } from 'vue'
8
8
  import type {
9
+ CreateSpaceInput,
9
10
  CreateReplyInput,
10
11
  CreateThreadInput,
12
+ DiscussionBrowseMode,
11
13
  DiscussionCategorySummary,
12
14
  DiscussionStoreConfig,
13
15
  DiscussionTag,
16
+ LoadSpacesOptions,
17
+ QuotedReply,
14
18
  QuoteResponse,
15
19
  Reply,
16
20
  SaveDraftOptions,
17
21
  Space,
18
22
  SpaceMembership,
23
+ SpaceThreadFilter,
19
24
  SpaceThreadSort,
20
25
  Thread,
21
26
  UpdateThreadInput,
@@ -252,6 +257,8 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
252
257
  const spaceMemberships = ref<Record<string, SpaceMembership>>({})
253
258
  const spaceMembershipLoading = ref<Record<string, boolean>>({})
254
259
  const threads = ref<Thread[]>([])
260
+ const browseThreadList = ref<Thread[]>([])
261
+ const currentBrowseMode = ref<DiscussionBrowseMode | null>(null)
255
262
  const currentThread = ref<Thread | null>(null)
256
263
  const replies = ref<Reply[]>([])
257
264
  const currentReplySort = ref<'best' | 'top' | 'new' | 'controversial'>('best')
@@ -261,8 +268,10 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
261
268
  const repliesLoadingState = createLoadingState()
262
269
 
263
270
  const loading = ref(false)
271
+ let activeLoadingRequests = 0
264
272
  const error = ref<string | null>(null)
265
273
  const nextCursor = ref<string | null>(null)
274
+ const browseNextCursor = ref<string | null>(null)
266
275
  const repliesNextCursor = ref<string | null>(null)
267
276
 
268
277
  const latestSpaceSlug = ref<string | null>(null)
@@ -365,7 +374,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
365
374
  }
366
375
  }
367
376
 
368
- function resolveQuotedReply(reply: Reply, existingReply?: Reply | null): Reply['quoted_reply'] {
377
+ function resolveQuotedReply(reply: Reply, existingReply?: Reply | null): QuotedReply | null {
369
378
  const quotedReplyId = typeof reply.quoted_reply_id === 'string'
370
379
  ? reply.quoted_reply_id.trim()
371
380
  : ''
@@ -393,6 +402,19 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
393
402
  return hasQuotedReplyBody(incomingQuotedReply) ? incomingQuotedReply : null
394
403
  }
395
404
 
405
+ /**
406
+ * Check whether a reply-like object carries a truthy `is_author` flag.
407
+ * The field is not part of the Reply TS interface (it is user-specific
408
+ * metadata injected by the API) so we access it reflectively.
409
+ */
410
+ function hasReplyAuthorFlag(reply: Reply | Record<string, unknown>): boolean {
411
+ return Reflect.get(reply as object, 'is_author') === true
412
+ }
413
+
414
+ function setReplyAuthorFlag(reply: Reply | Record<string, unknown>, value: boolean): void {
415
+ Reflect.set(reply as object, 'is_author', value)
416
+ }
417
+
396
418
  function normalizeReplyPayload(reply: Reply, existingReply?: Reply | null): Reply {
397
419
  const normalized: Reply = { ...reply }
398
420
  const hasQuotedReplyId = typeof normalized.quoted_reply_id === 'string'
@@ -651,6 +673,16 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
651
673
  REPLY: 'reply',
652
674
  } as const
653
675
 
676
+ function beginLoading(): void {
677
+ activeLoadingRequests += 1
678
+ loading.value = true
679
+ }
680
+
681
+ function endLoading(): void {
682
+ activeLoadingRequests = Math.max(0, activeLoadingRequests - 1)
683
+ loading.value = activeLoadingRequests > 0
684
+ }
685
+
654
686
  function flattenTree(tree: Space[]): Space[] {
655
687
  const result: Space[] = []
656
688
 
@@ -675,13 +707,38 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
675
707
  return spaces.value.filter((space) => space.is_leaf !== false)
676
708
  }
677
709
 
678
- async function loadSpaces(): Promise<unknown> {
710
+ function buildSpaceQueryString(options?: LoadSpacesOptions): string {
711
+ const params = new URLSearchParams()
712
+
713
+ params.set('tree', options?.tree === false ? '0' : '1')
714
+
715
+ if (options?.kind) {
716
+ params.set('kind', options.kind)
717
+ }
718
+
719
+ if (typeof options?.scope_type === 'string' && options.scope_type.trim().length > 0) {
720
+ params.set('scope_type', options.scope_type.trim())
721
+ }
722
+
723
+ if (typeof options?.scope_id === 'string' && options.scope_id.trim().length > 0) {
724
+ params.set('scope_id', options.scope_id.trim())
725
+ }
726
+
727
+ if (typeof options?.parent_id === 'string' && options.parent_id.trim().length > 0) {
728
+ params.set('parent_id', options.parent_id.trim())
729
+ }
730
+
731
+ return params.toString()
732
+ }
733
+
734
+ async function loadSpaces(options?: LoadSpacesOptions): Promise<unknown> {
679
735
  spacesLoadingState.setLoading(true)
680
- loading.value = true
736
+ beginLoading()
681
737
  error.value = null
682
738
 
683
739
  try {
684
- const response = await client.get<unknown>('/v1/discussion/spaces?tree=1')
740
+ const queryString = buildSpaceQueryString(options)
741
+ const response = await client.get<unknown>(`/v1/discussion/spaces?${queryString}`)
685
742
  const responseData = toRecord(response.data)
686
743
  const treeData = Array.isArray(responseData?.items)
687
744
  ? responseData.items as Space[]
@@ -691,10 +748,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
691
748
 
692
749
  spaceTree.value = treeData
693
750
  spaces.value = flattenTree(treeData)
694
-
695
- if (treeData.length === 0) {
696
- spacesLoadingState.setEmpty(true)
697
- }
751
+ spacesLoadingState.setEmpty(treeData.length === 0)
698
752
 
699
753
  return response.data
700
754
  } catch (err) {
@@ -705,7 +759,66 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
705
759
  throw err
706
760
  } finally {
707
761
  spacesLoadingState.setLoading(false)
708
- loading.value = false
762
+ endLoading()
763
+ }
764
+ }
765
+
766
+ async function createSpace(input: CreateSpaceInput): Promise<unknown> {
767
+ beginLoading()
768
+ error.value = null
769
+
770
+ try {
771
+ const response = await client.post<unknown>('/v1/discussion/spaces', {
772
+ ...input,
773
+ ...(typeof input.parent_id === 'string' ? { parent_id: input.parent_id } : { parent_id: null }),
774
+ ...(typeof input.description === 'string' && input.description.trim().length > 0
775
+ ? { description: input.description.trim() }
776
+ : {}),
777
+ ...(input.meta ? { meta: input.meta } : {}),
778
+ })
779
+
780
+ return response.data
781
+ } catch (err) {
782
+ logger.error('Failed to create discussion space:', err)
783
+ error.value = getErrorMessage(err, 'Failed to create discussion space')
784
+ throw err
785
+ } finally {
786
+ endLoading()
787
+ }
788
+ }
789
+
790
+ async function moveSpace(spaceId: string, parentId?: string | null): Promise<unknown> {
791
+ beginLoading()
792
+ error.value = null
793
+
794
+ try {
795
+ const response = await client.post<unknown>(`/v1/discussion/spaces/${spaceId}/move`, {
796
+ parent_id: parentId ?? null,
797
+ })
798
+
799
+ return response.data
800
+ } catch (err) {
801
+ logger.error('Failed to move discussion space:', err)
802
+ error.value = getErrorMessage(err, 'Failed to move discussion space')
803
+ throw err
804
+ } finally {
805
+ endLoading()
806
+ }
807
+ }
808
+
809
+ async function reorderSpaces(ids: string[]): Promise<unknown> {
810
+ beginLoading()
811
+ error.value = null
812
+
813
+ try {
814
+ const response = await client.post<unknown>('/v1/discussion/spaces/reorder', { ids })
815
+ return response.data
816
+ } catch (err) {
817
+ logger.error('Failed to reorder discussion spaces:', err)
818
+ error.value = getErrorMessage(err, 'Failed to reorder discussion spaces')
819
+ throw err
820
+ } finally {
821
+ endLoading()
709
822
  }
710
823
  }
711
824
 
@@ -813,18 +926,16 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
813
926
  async function loadThreads(
814
927
  spaceSlug: string,
815
928
  cursor?: string,
816
- options?: { signal?: AbortSignal; sort?: SpaceThreadSort }
929
+ options?: { signal?: AbortSignal; sort?: SpaceThreadSort; filter?: SpaceThreadFilter }
817
930
  ): Promise<unknown> {
818
931
  if (!cursor || latestSpaceSlug.value === null) {
819
932
  latestSpaceSlug.value = spaceSlug
820
933
  }
821
934
 
822
935
  threadsLoadingState.setLoading(true)
823
- loading.value = true
936
+ beginLoading()
824
937
  error.value = null
825
938
 
826
- let isStale = false
827
-
828
939
  try {
829
940
  const queryParams = new URLSearchParams()
830
941
  if (cursor) {
@@ -833,6 +944,9 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
833
944
  if (options?.sort) {
834
945
  queryParams.set('sort', options.sort)
835
946
  }
947
+ if (options?.filter && options.filter !== 'all') {
948
+ queryParams.set('filter', options.filter)
949
+ }
836
950
 
837
951
  const queryString = queryParams.toString()
838
952
  const url = `/v1/discussion/spaces/${spaceSlug}/threads${queryString.length > 0 ? `?${queryString}` : ''}`
@@ -853,7 +967,6 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
853
967
  }
854
968
 
855
969
  if (latestSpaceSlug.value !== spaceSlug) {
856
- isStale = true
857
970
  return response.data
858
971
  }
859
972
 
@@ -884,7 +997,6 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
884
997
  return response.data
885
998
  } catch (err) {
886
999
  if (options?.signal?.aborted || latestSpaceSlug.value !== spaceSlug) {
887
- isStale = true
888
1000
  return undefined
889
1001
  }
890
1002
 
@@ -909,10 +1021,81 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
909
1021
  error.value = errorMessage
910
1022
  throw err
911
1023
  } finally {
912
- if (!isStale) {
913
- threadsLoadingState.setLoading(false)
914
- loading.value = false
1024
+ threadsLoadingState.setLoading(false)
1025
+ endLoading()
1026
+ }
1027
+ }
1028
+
1029
+ async function browseThreads(
1030
+ view: DiscussionBrowseMode,
1031
+ cursor?: string,
1032
+ options?: { signal?: AbortSignal }
1033
+ ): Promise<unknown> {
1034
+ if (!cursor || currentBrowseMode.value !== view) {
1035
+ currentBrowseMode.value = view
1036
+ }
1037
+
1038
+ threadsLoadingState.setLoading(true)
1039
+ beginLoading()
1040
+ error.value = null
1041
+
1042
+ try {
1043
+ const queryParams = new URLSearchParams()
1044
+ if (cursor) {
1045
+ queryParams.set('cursor', cursor)
915
1046
  }
1047
+
1048
+ const queryString = queryParams.toString()
1049
+ const url = `/v1/discussion/threads/${view}${queryString.length > 0 ? `?${queryString}` : ''}`
1050
+ const requestConfig = {
1051
+ ...(options?.signal ? { signal: options.signal } : {}),
1052
+ }
1053
+
1054
+ let response: Awaited<ReturnType<typeof client.get<unknown>>>
1055
+
1056
+ try {
1057
+ response = await client.get<unknown>(url, requestConfig)
1058
+ } catch (requestError) {
1059
+ if (!isTransientThreadListError(requestError) || options?.signal?.aborted) {
1060
+ throw requestError
1061
+ }
1062
+
1063
+ response = await client.get<unknown>(url, requestConfig)
1064
+ }
1065
+
1066
+ if (currentBrowseMode.value !== view) {
1067
+ return response.data
1068
+ }
1069
+
1070
+ const newThreads = getThreadsFromPayload(response.data).map((thread) => normalizeThreadReplyCount(thread))
1071
+
1072
+ if (cursor) {
1073
+ browseThreadList.value = mergeUniqueById(browseThreadList.value, newThreads)
1074
+ } else {
1075
+ browseThreadList.value = newThreads
1076
+
1077
+ if (newThreads.length === 0) {
1078
+ threadsLoadingState.setEmpty(true)
1079
+ }
1080
+ }
1081
+
1082
+ browseNextCursor.value = getThreadNextCursorFromPayload(response.data)
1083
+ currentSpace.value = null
1084
+
1085
+ return response.data
1086
+ } catch (err) {
1087
+ if (options?.signal?.aborted || currentBrowseMode.value !== view) {
1088
+ return undefined
1089
+ }
1090
+
1091
+ logger.error('Failed to browse discussion threads:', err)
1092
+ const errorMessage = getErrorMessage(err, 'Failed to load discussion threads')
1093
+ threadsLoadingState.setError(new Error(errorMessage))
1094
+ error.value = errorMessage
1095
+ throw err
1096
+ } finally {
1097
+ threadsLoadingState.setLoading(false)
1098
+ endLoading()
916
1099
  }
917
1100
  }
918
1101
 
@@ -932,29 +1115,29 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
932
1115
  latestThreadId.value = threadId
933
1116
 
934
1117
  cleanupRealtimeChannels()
935
- loading.value = true
1118
+ beginLoading()
936
1119
  error.value = null
1120
+ replies.value = []
1121
+ repliesNextCursor.value = null
1122
+ currentReplySort.value = 'best'
937
1123
 
938
1124
  if (!threadId) {
939
1125
  error.value = 'Missing thread identifier'
940
- loading.value = false
1126
+ endLoading()
941
1127
  return null
942
1128
  }
943
1129
 
944
1130
  // Accept both UUID and slug identifiers — the backend resolves by ID or slug
945
1131
  if (!/^[\w-]+$/u.test(threadId)) {
946
1132
  error.value = 'Invalid thread identifier format'
947
- loading.value = false
1133
+ endLoading()
948
1134
  return null
949
1135
  }
950
1136
 
951
- let isStale = false
952
-
953
1137
  try {
954
1138
  const response = await client.get<unknown>(`/v1/discussion/threads/${threadId}`)
955
1139
 
956
1140
  if (latestThreadId.value !== threadId) {
957
- isStale = true
958
1141
  return null
959
1142
  }
960
1143
 
@@ -1011,7 +1194,6 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1011
1194
  return currentThread.value
1012
1195
  } catch (err) {
1013
1196
  if (latestThreadId.value !== threadId) {
1014
- isStale = true
1015
1197
  return null
1016
1198
  }
1017
1199
 
@@ -1037,9 +1219,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1037
1219
 
1038
1220
  throw err
1039
1221
  } finally {
1040
- if (!isStale) {
1041
- loading.value = false
1042
- }
1222
+ endLoading()
1043
1223
  }
1044
1224
  }
1045
1225
 
@@ -1052,11 +1232,9 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1052
1232
  latestThreadId.value = threadId
1053
1233
  }
1054
1234
 
1055
- loading.value = true
1235
+ beginLoading()
1056
1236
  error.value = null
1057
1237
 
1058
- let isStale = false
1059
-
1060
1238
  try {
1061
1239
  const queryParts: string[] = []
1062
1240
  if (cursor) {
@@ -1070,7 +1248,6 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1070
1248
  const response = await client.get<unknown>(`/v1/discussion/threads/${threadId}/replies${queryString}`)
1071
1249
 
1072
1250
  if (latestThreadId.value !== threadId) {
1073
- isStale = true
1074
1251
  return undefined
1075
1252
  }
1076
1253
 
@@ -1139,7 +1316,6 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1139
1316
  return response.data
1140
1317
  } catch (err) {
1141
1318
  if (latestThreadId.value !== threadId) {
1142
- isStale = true
1143
1319
  return undefined
1144
1320
  }
1145
1321
 
@@ -1147,9 +1323,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1147
1323
  error.value = getErrorMessage(err, 'Failed to load replies')
1148
1324
  throw err
1149
1325
  } finally {
1150
- if (!isStale) {
1151
- loading.value = false
1152
- }
1326
+ endLoading()
1153
1327
  }
1154
1328
  }
1155
1329
 
@@ -1168,10 +1342,19 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1168
1342
  if (existingReplyIndex !== -1) {
1169
1343
  const existingReply = replies.value[existingReplyIndex]
1170
1344
  if (existingReply) {
1171
- replies.value[existingReplyIndex] = {
1345
+ const merged = {
1172
1346
  ...existingReply,
1173
1347
  ...reply,
1174
1348
  }
1349
+
1350
+ // Broadcast payloads lack an authenticated user context, so
1351
+ // is_author is always false in them. Preserve the value from
1352
+ // the original authenticated API response when it was true.
1353
+ if (hasReplyAuthorFlag(existingReply) && !hasReplyAuthorFlag(reply)) {
1354
+ setReplyAuthorFlag(merged, true)
1355
+ }
1356
+
1357
+ replies.value[existingReplyIndex] = merged
1175
1358
  }
1176
1359
 
1177
1360
  return false
@@ -1222,7 +1405,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1222
1405
  input: CreateThreadInput
1223
1406
  ): Promise<DiscussionCreateThreadResponse> {
1224
1407
  cleanupRealtimeChannels()
1225
- loading.value = true
1408
+ beginLoading()
1226
1409
  error.value = null
1227
1410
 
1228
1411
  try {
@@ -1253,12 +1436,12 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1253
1436
  error.value = getErrorMessage(err, 'Failed to create thread')
1254
1437
  throw err
1255
1438
  } finally {
1256
- loading.value = false
1439
+ endLoading()
1257
1440
  }
1258
1441
  }
1259
1442
 
1260
1443
  async function loadTagCategories(query = ''): Promise<DiscussionTag[]> {
1261
- loading.value = true
1444
+ beginLoading()
1262
1445
  error.value = null
1263
1446
 
1264
1447
  try {
@@ -1293,7 +1476,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1293
1476
  error.value = getErrorMessage(err, 'Failed to load tag suggestions')
1294
1477
  throw err
1295
1478
  } finally {
1296
- loading.value = false
1479
+ endLoading()
1297
1480
  }
1298
1481
  }
1299
1482
 
@@ -1597,13 +1780,19 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1597
1780
  return
1598
1781
  }
1599
1782
 
1600
- replies.value[replyIndex] = normalizeReplyPayload(
1601
- {
1602
- ...existingReply,
1603
- ...payload,
1604
- },
1605
- existingReply
1606
- )
1783
+ const merged = {
1784
+ ...existingReply,
1785
+ ...payload,
1786
+ }
1787
+
1788
+ // Broadcast payloads lack an authenticated user context, so
1789
+ // is_author is always false in them. Preserve the value from
1790
+ // the original authenticated API response when it was true.
1791
+ if (hasReplyAuthorFlag(existingReply) && !hasReplyAuthorFlag(payload)) {
1792
+ setReplyAuthorFlag(merged, true)
1793
+ }
1794
+
1795
+ replies.value[replyIndex] = normalizeReplyPayload(merged, existingReply)
1607
1796
  }
1608
1797
 
1609
1798
  function handleRealtimeReplyReaction(payload: ReplyReactionRealtimePayload | null | undefined): void {
@@ -1741,8 +1930,14 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1741
1930
  }
1742
1931
  }
1743
1932
 
1744
- async function searchThreads(query: string, spaceSlug?: string): Promise<unknown> {
1745
- loading.value = true
1933
+ async function searchThreads(
1934
+ query: string,
1935
+ spaceSlug?: string,
1936
+ options?: {
1937
+ signal?: AbortSignal
1938
+ },
1939
+ ): Promise<unknown> {
1940
+ beginLoading()
1746
1941
  error.value = null
1747
1942
 
1748
1943
  try {
@@ -1751,33 +1946,49 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1751
1946
  params.append('space', spaceSlug)
1752
1947
  }
1753
1948
 
1754
- const response = await client.get<unknown>(`/v1/discussion/search/threads?${params.toString()}`)
1949
+ const requestConfig = options?.signal
1950
+ ? { signal: options.signal }
1951
+ : undefined
1952
+ const response = await client.get<unknown>(`/v1/discussion/search/threads?${params.toString()}`, requestConfig)
1755
1953
  return response.data
1756
1954
  } catch (err) {
1955
+ if (options?.signal?.aborted) {
1956
+ return undefined
1957
+ }
1958
+
1757
1959
  logger.error('Failed to search threads:', err)
1758
1960
  error.value = getErrorMessage(err, 'Failed to search')
1759
1961
  throw err
1760
1962
  } finally {
1761
- loading.value = false
1963
+ endLoading()
1762
1964
  }
1763
1965
  }
1764
1966
 
1765
- async function searchThreadsInSpace(query: string, spaceId: string): Promise<Thread[]> {
1967
+ async function searchThreadsInSpace(
1968
+ query: string,
1969
+ spaceId: string,
1970
+ options?: {
1971
+ signal?: AbortSignal
1972
+ },
1973
+ ): Promise<Thread[]> {
1766
1974
  const normalizedQuery = query.trim()
1767
1975
  if (normalizedQuery.length < 2) {
1768
1976
  return []
1769
1977
  }
1770
1978
 
1771
- loading.value = true
1979
+ beginLoading()
1772
1980
  error.value = null
1773
1981
 
1774
1982
  try {
1983
+ const requestConfig = options?.signal
1984
+ ? { signal: options.signal }
1985
+ : undefined
1775
1986
  const response = await client.post<ThreadSearchResponseBody>('/v1/discussion/search', {
1776
1987
  query: normalizedQuery,
1777
1988
  space_id: spaceId,
1778
1989
  sort_by: 'relevance',
1779
1990
  limit: 50,
1780
- })
1991
+ }, requestConfig)
1781
1992
 
1782
1993
  const rows = extractThreadSearchRows(response.data)
1783
1994
  if (rows.length > 0) {
@@ -1786,41 +1997,57 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1786
1997
 
1787
1998
  return filterLoadedThreadsByQuery(normalizedQuery, spaceId)
1788
1999
  } catch (err) {
2000
+ if (options?.signal?.aborted) {
2001
+ return []
2002
+ }
2003
+
1789
2004
  logger.error('Failed to search threads in space:', err)
1790
2005
  error.value = isAxiosError(err)
1791
2006
  ? err.response?.data?.message ?? 'Failed to search threads'
1792
2007
  : 'Failed to search threads'
1793
2008
  throw err
1794
2009
  } finally {
1795
- loading.value = false
2010
+ endLoading()
1796
2011
  }
1797
2012
  }
1798
2013
 
1799
- async function searchThreadsGlobally(query: string): Promise<Thread[]> {
2014
+ async function searchThreadsGlobally(
2015
+ query: string,
2016
+ options?: {
2017
+ signal?: AbortSignal
2018
+ },
2019
+ ): Promise<Thread[]> {
1800
2020
  const normalizedQuery = query.trim()
1801
2021
  if (normalizedQuery.length < 2) {
1802
2022
  return []
1803
2023
  }
1804
2024
 
1805
- loading.value = true
2025
+ beginLoading()
1806
2026
  error.value = null
1807
2027
 
1808
2028
  try {
2029
+ const requestConfig = options?.signal
2030
+ ? { signal: options.signal }
2031
+ : undefined
1809
2032
  const response = await client.post<ThreadSearchResponseBody>('/v1/discussion/search', {
1810
2033
  query: normalizedQuery,
1811
2034
  sort_by: 'relevance',
1812
2035
  limit: 50,
1813
- })
2036
+ }, requestConfig)
1814
2037
 
1815
2038
  return extractThreadSearchRows(response.data).map((row) => mapSearchRowToThread(row))
1816
2039
  } catch (err) {
2040
+ if (options?.signal?.aborted) {
2041
+ return []
2042
+ }
2043
+
1817
2044
  logger.error('Failed to search threads globally:', err)
1818
2045
  error.value = isAxiosError(err)
1819
2046
  ? err.response?.data?.message ?? 'Failed to search threads'
1820
2047
  : 'Failed to search threads'
1821
2048
  throw err
1822
2049
  } finally {
1823
- loading.value = false
2050
+ endLoading()
1824
2051
  }
1825
2052
  }
1826
2053
 
@@ -1852,6 +2079,15 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1852
2079
  thread.slug = row.slug
1853
2080
  }
1854
2081
 
2082
+ const spaceId = row.space_id ?? fallbackSpaceId ?? ''
2083
+ if (typeof row.space_name === 'string' && typeof row.space_slug === 'string' && spaceId) {
2084
+ thread.space = {
2085
+ id: spaceId,
2086
+ slug: row.space_slug,
2087
+ name: row.space_name,
2088
+ }
2089
+ }
2090
+
1855
2091
  return thread
1856
2092
  }
1857
2093
 
@@ -1897,7 +2133,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1897
2133
  }
1898
2134
 
1899
2135
  async function setThreadPinned(threadId: string, pinned: boolean): Promise<void> {
1900
- loading.value = true
2136
+ beginLoading()
1901
2137
  error.value = null
1902
2138
 
1903
2139
  try {
@@ -1918,12 +2154,12 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1918
2154
  error.value = getErrorMessage(err, 'Failed to update pinned status')
1919
2155
  throw err
1920
2156
  } finally {
1921
- loading.value = false
2157
+ endLoading()
1922
2158
  }
1923
2159
  }
1924
2160
 
1925
2161
  async function setThreadLocked(threadId: string, locked: boolean): Promise<void> {
1926
- loading.value = true
2162
+ beginLoading()
1927
2163
  error.value = null
1928
2164
 
1929
2165
  try {
@@ -1943,12 +2179,12 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1943
2179
  error.value = getErrorMessage(err, 'Failed to update locked status')
1944
2180
  throw err
1945
2181
  } finally {
1946
- loading.value = false
2182
+ endLoading()
1947
2183
  }
1948
2184
  }
1949
2185
 
1950
2186
  async function moveThread(threadId: string, toSpaceSlug: string): Promise<void> {
1951
- loading.value = true
2187
+ beginLoading()
1952
2188
  error.value = null
1953
2189
 
1954
2190
  try {
@@ -1959,12 +2195,22 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1959
2195
  const responseData = toRecord(response.data)
1960
2196
  const movedThread = (responseData?.data ?? null) as Thread | null
1961
2197
  const destinationSpace = spaces.value.find((space) => space.slug === toSpaceSlug)
2198
+ const destinationSpaceSummary = destinationSpace
2199
+ ? {
2200
+ id: destinationSpace.id,
2201
+ slug: destinationSpace.slug,
2202
+ name: destinationSpace.name,
2203
+ }
2204
+ : null
1962
2205
 
1963
2206
  if (currentThread.value?.id === threadId && currentThread.value) {
1964
2207
  currentThread.value = {
1965
2208
  ...currentThread.value,
1966
2209
  ...(movedThread ?? {}),
1967
- ...(destinationSpace ? { space_id: destinationSpace.id } : {}),
2210
+ ...(destinationSpaceSummary ? {
2211
+ space_id: destinationSpaceSummary.id,
2212
+ space: destinationSpaceSummary,
2213
+ } : {}),
1968
2214
  }
1969
2215
  }
1970
2216
 
@@ -1982,7 +2228,10 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1982
2228
  threads.value[threadIndex] = {
1983
2229
  ...existingThread,
1984
2230
  ...(movedThread ?? {}),
1985
- ...(destinationSpace ? { space_id: destinationSpace.id } : {}),
2231
+ ...(destinationSpaceSummary ? {
2232
+ space_id: destinationSpaceSummary.id,
2233
+ space: destinationSpaceSummary,
2234
+ } : {}),
1986
2235
  }
1987
2236
  }
1988
2237
  }
@@ -1993,12 +2242,12 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1993
2242
  error.value = getErrorMessage(err, 'Failed to move thread')
1994
2243
  throw err
1995
2244
  } finally {
1996
- loading.value = false
2245
+ endLoading()
1997
2246
  }
1998
2247
  }
1999
2248
 
2000
2249
  async function updateThread(threadId: string, updates: UpdateThreadInput): Promise<unknown> {
2001
- loading.value = true
2250
+ beginLoading()
2002
2251
  error.value = null
2003
2252
 
2004
2253
  try {
@@ -2031,12 +2280,12 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
2031
2280
  error.value = getErrorMessage(err, 'Failed to update thread')
2032
2281
  throw err
2033
2282
  } finally {
2034
- loading.value = false
2283
+ endLoading()
2035
2284
  }
2036
2285
  }
2037
2286
 
2038
2287
  async function deleteThread(threadId: string): Promise<unknown> {
2039
- loading.value = true
2288
+ beginLoading()
2040
2289
  error.value = null
2041
2290
 
2042
2291
  try {
@@ -2054,12 +2303,12 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
2054
2303
  error.value = getErrorMessage(err, 'Failed to delete thread')
2055
2304
  throw err
2056
2305
  } finally {
2057
- loading.value = false
2306
+ endLoading()
2058
2307
  }
2059
2308
  }
2060
2309
 
2061
2310
  async function restoreThread(threadId: string): Promise<Thread> {
2062
- loading.value = true
2311
+ beginLoading()
2063
2312
  error.value = null
2064
2313
 
2065
2314
  try {
@@ -2102,12 +2351,12 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
2102
2351
  error.value = getErrorMessage(err, 'Failed to restore thread')
2103
2352
  throw err
2104
2353
  } finally {
2105
- loading.value = false
2354
+ endLoading()
2106
2355
  }
2107
2356
  }
2108
2357
 
2109
2358
  async function deleteReply(replyId: string): Promise<void> {
2110
- loading.value = true
2359
+ beginLoading()
2111
2360
  error.value = null
2112
2361
 
2113
2362
  const replyIndex = replies.value.findIndex((reply) => reply.id === replyId)
@@ -2171,7 +2420,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
2171
2420
  error.value = getErrorMessage(err, 'Failed to delete reply')
2172
2421
  throw err
2173
2422
  } finally {
2174
- loading.value = false
2423
+ endLoading()
2175
2424
  }
2176
2425
  }
2177
2426
 
@@ -2432,16 +2681,23 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
2432
2681
  spaceMembershipLoading,
2433
2682
  cleanupRealtimeChannels,
2434
2683
  threads,
2684
+ browseThreadList,
2685
+ currentBrowseMode,
2435
2686
  currentThread,
2436
2687
  replies,
2688
+ currentReplySort,
2437
2689
  loading,
2438
2690
  error,
2439
2691
  nextCursor,
2692
+ browseNextCursor,
2440
2693
  repliesNextCursor,
2441
2694
  spacesLoadingState,
2442
2695
  threadsLoadingState,
2443
2696
  repliesLoadingState,
2444
2697
  loadSpaces,
2698
+ createSpace,
2699
+ moveSpace,
2700
+ reorderSpaces,
2445
2701
  loadSpaceDetail,
2446
2702
  loadSpaceMembership,
2447
2703
  joinSpace,
@@ -2451,6 +2707,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
2451
2707
  rootSpaces,
2452
2708
  leafSpaces,
2453
2709
  loadThreads,
2710
+ browseThreads,
2454
2711
  loadThread,
2455
2712
  loadReplies,
2456
2713
  createThread,