@codingfactory/socialkit-vue 0.7.18 → 0.7.20

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.
@@ -57,11 +57,30 @@ const LEGACY_CLIENT_RETRYABLE_ERROR_FRAGMENTS = [
57
57
  'ensureCsrf is not a function',
58
58
  ]
59
59
  const TRANSIENT_PUBLISH_RETRYABLE_ERROR_FRAGMENTS = [
60
+ 'request failed with status code 500',
60
61
  'request failed with status code 502',
61
62
  'request failed with status code 503',
62
63
  'request failed with status code 504',
64
+ 'something went wrong. please try again.',
63
65
  ]
64
66
 
67
+ const SERVER_ERROR_MESSAGE_PATTERNS = [
68
+ /sqlstate\[/i,
69
+ /relation ".*" does not exist/i,
70
+ /undefined table/i,
71
+ /column ".*" does not exist/i,
72
+ /duplicate key value violates/i,
73
+ /deadlock detected/i,
74
+ /connection refused/i,
75
+ /too many connections/i,
76
+ ]
77
+
78
+ const GENERIC_SERVER_ERROR_MESSAGE = 'Something went wrong. Please try again.'
79
+
80
+ function isInternalServerError(message: string): boolean {
81
+ return SERVER_ERROR_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))
82
+ }
83
+
65
84
  function normalizePageSize(pageSize: number | undefined): number {
66
85
  const parsed = Number(pageSize ?? 10)
67
86
  if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -131,11 +150,13 @@ export function createContentStoreDefinition(config: ContentStoreConfig) {
131
150
  }
132
151
 
133
152
  const DISCIPLINE_ERROR_CODES = new Set([
153
+ 'ACCOUNT_LIMITED',
134
154
  'ACCOUNT_WRITE_TIMEOUT',
135
155
  'ACCOUNT_BANNED',
136
156
  ])
137
157
 
138
158
  const DISCIPLINE_ERROR_MESSAGES: Record<string, string> = {
159
+ ACCOUNT_LIMITED: 'Your account is temporarily limited',
139
160
  ACCOUNT_WRITE_TIMEOUT: 'Your account is temporarily suspended. You cannot post at this time.',
140
161
  ACCOUNT_BANNED: 'Your account has been suspended. You cannot post.',
141
162
  }
@@ -161,6 +182,7 @@ export function createContentStoreDefinition(config: ContentStoreConfig) {
161
182
  const lower = errorMessage.toLowerCase()
162
183
  return LEGACY_CLIENT_RETRYABLE_ERROR_FRAGMENTS.some((fragment) => lower.includes(fragment.toLowerCase()))
163
184
  || TRANSIENT_PUBLISH_RETRYABLE_ERROR_FRAGMENTS.some((fragment) => lower.includes(fragment))
185
+ || isInternalServerError(errorMessage)
164
186
  }
165
187
 
166
188
  const isDisciplineAxiosError = (err: unknown): boolean => {
@@ -174,15 +196,18 @@ export function createContentStoreDefinition(config: ContentStoreConfig) {
174
196
  }
175
197
 
176
198
  const resolvePendingPostErrorMessage = (err: unknown): string => {
177
- const responseData = (err as {
199
+ const errorShape = err as {
178
200
  response?: {
201
+ status?: number
179
202
  data?: {
180
203
  message?: string
181
204
  code?: string
182
205
  errors?: Record<string, string[] | string>
183
206
  }
184
207
  }
185
- }).response?.data
208
+ }
209
+ const responseData = errorShape.response?.data
210
+ const httpStatus = errorShape.response?.status
186
211
 
187
212
  if (typeof responseData?.code === 'string' && responseData.code in DISCIPLINE_ERROR_MESSAGES) {
188
213
  return DISCIPLINE_ERROR_MESSAGES[responseData.code] ?? 'Failed to create post'
@@ -198,14 +223,26 @@ export function createContentStoreDefinition(config: ContentStoreConfig) {
198
223
  typeof message === 'string' && message.trim() !== ''
199
224
  ))
200
225
  if (firstFieldError) {
201
- return firstFieldError
226
+ return isInternalServerError(firstFieldError) ? GENERIC_SERVER_ERROR_MESSAGE : firstFieldError
227
+ }
228
+
229
+ if (typeof httpStatus === 'number' && httpStatus >= 500) {
230
+ return GENERIC_SERVER_ERROR_MESSAGE
202
231
  }
203
232
 
204
233
  if (typeof responseData?.message === 'string' && responseData.message.trim() !== '') {
234
+ if (isInternalServerError(responseData.message)) {
235
+ return GENERIC_SERVER_ERROR_MESSAGE
236
+ }
237
+
205
238
  return responseData.message
206
239
  }
207
240
 
208
241
  if (err instanceof Error && err.message.trim() !== '') {
242
+ if (isInternalServerError(err.message)) {
243
+ return GENERIC_SERVER_ERROR_MESSAGE
244
+ }
245
+
209
246
  return err.message
210
247
  }
211
248
 
@@ -219,6 +219,18 @@ function isTransientThreadListError(error: unknown): boolean {
219
219
  return status === 502 || status === 503 || status === 504
220
220
  }
221
221
 
222
+ const DISCUSSION_SPACE_CHANNEL_PREFIX = 'discussions.space.'
223
+ const DISCUSSION_THREAD_CHANNEL_PREFIX = 'discussions.thread.'
224
+
225
+ function extractRealtimeChannelId(channelName: string | null, prefix: string): string | null {
226
+ if (!channelName || !channelName.startsWith(prefix)) {
227
+ return null
228
+ }
229
+
230
+ const channelId = channelName.slice(prefix.length)
231
+ return channelId.length > 0 ? channelId : null
232
+ }
233
+
222
234
  export type DiscussionStoreReturn = ReturnType<ReturnType<typeof createDiscussionStoreDefinition>>
223
235
 
224
236
  export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
@@ -226,6 +238,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
226
238
  client,
227
239
  getCurrentUserId,
228
240
  getEcho,
241
+ onEchoInitialized,
229
242
  onEchoReconnected,
230
243
  createLoadingState,
231
244
  logger = console,
@@ -257,6 +270,8 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
257
270
 
258
271
  let activeSpaceChannel: string | null = null
259
272
  let activeThreadChannel: string | null = null
273
+ let desiredSpaceChannel: string | null = null
274
+ let desiredThreadChannel: string | null = null
260
275
 
261
276
  const locallyCreatedReplyIds = new Set<string>()
262
277
  const pendingReplyCreations = new Map<string, number>()
@@ -420,6 +435,71 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
420
435
  return mergedItems
421
436
  }
422
437
 
438
+ /**
439
+ * Reorder a flat list of replies (which may arrive in chronological or
440
+ * score order from the API) into depth-first tree order so that children
441
+ * always appear directly after their parent branch. The relative order
442
+ * of siblings (as returned by the backend sort) is preserved.
443
+ */
444
+ function reorderIntoTreeOrder(flatReplies: Reply[]): Reply[] {
445
+ if (flatReplies.length <= 1) {
446
+ return flatReplies
447
+ }
448
+
449
+ const replyIds = new Set<string>()
450
+ for (const reply of flatReplies) {
451
+ replyIds.add(reply.id)
452
+ }
453
+
454
+ // Group children by their parent. Replies whose parent is not in the
455
+ // current list are treated as roots (parentKey = '').
456
+ const childrenMap = new Map<string, Reply[]>()
457
+ childrenMap.set('', [])
458
+
459
+ for (const reply of flatReplies) {
460
+ const parentId = reply.parent_reply_id
461
+ const key = typeof parentId === 'string' && parentId !== '' && replyIds.has(parentId)
462
+ ? parentId
463
+ : ''
464
+
465
+ const siblings = childrenMap.get(key)
466
+ if (siblings) {
467
+ siblings.push(reply)
468
+ } else {
469
+ childrenMap.set(key, [reply])
470
+ }
471
+ }
472
+
473
+ const result: Reply[] = []
474
+
475
+ function dfs(parentKey: string): void {
476
+ const children = childrenMap.get(parentKey)
477
+ if (!children) {
478
+ return
479
+ }
480
+
481
+ for (const child of children) {
482
+ result.push(child)
483
+ dfs(child.id)
484
+ }
485
+ }
486
+
487
+ dfs('')
488
+
489
+ // If some replies were unreachable (orphaned cycles), append them so
490
+ // nothing is silently dropped.
491
+ if (result.length < flatReplies.length) {
492
+ const included = new Set(result.map((reply) => reply.id))
493
+ for (const reply of flatReplies) {
494
+ if (!included.has(reply.id)) {
495
+ result.push(reply)
496
+ }
497
+ }
498
+ }
499
+
500
+ return result
501
+ }
502
+
423
503
  function extractResponsePayload(responseData: unknown): unknown {
424
504
  const responseRecord = toRecord(responseData)
425
505
  if (!responseRecord) {
@@ -1026,9 +1106,9 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1026
1106
  currentReplySort.value = sortBy
1027
1107
 
1028
1108
  if (cursor) {
1029
- replies.value = mergeUniqueById(replies.value, filteredReplyBatch)
1109
+ replies.value = reorderIntoTreeOrder(mergeUniqueById(replies.value, filteredReplyBatch))
1030
1110
  } else {
1031
- replies.value = filteredReplyBatch
1111
+ replies.value = reorderIntoTreeOrder(filteredReplyBatch)
1032
1112
  hiddenOpeningReplyCountByThread.set(threadId, openingRepliesFilteredInBatch)
1033
1113
  subscribeToThreadRealtime(threadId)
1034
1114
  }
@@ -1217,6 +1297,9 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1217
1297
  }
1218
1298
 
1219
1299
  function cleanupRealtimeChannels(): void {
1300
+ desiredSpaceChannel = null
1301
+ desiredThreadChannel = null
1302
+
1220
1303
  const echo = getEcho()
1221
1304
  if (!echo) {
1222
1305
  activeSpaceChannel = null
@@ -1328,13 +1411,14 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1328
1411
  }
1329
1412
 
1330
1413
  function subscribeToSpaceRealtime(spaceId: string): void {
1414
+ const channelName = `${DISCUSSION_SPACE_CHANNEL_PREFIX}${spaceId}`
1415
+ desiredSpaceChannel = channelName
1416
+
1331
1417
  const echo = getEcho()
1332
1418
  if (!echo) {
1333
1419
  return
1334
1420
  }
1335
1421
 
1336
- const channelName = `discussions.space.${spaceId}`
1337
-
1338
1422
  if (activeSpaceChannel === channelName) {
1339
1423
  return
1340
1424
  }
@@ -1358,13 +1442,14 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1358
1442
  }
1359
1443
 
1360
1444
  function subscribeToThreadRealtime(threadId: string): void {
1445
+ const channelName = `${DISCUSSION_THREAD_CHANNEL_PREFIX}${threadId}`
1446
+ desiredThreadChannel = channelName
1447
+
1361
1448
  const echo = getEcho()
1362
1449
  if (!echo) {
1363
1450
  return
1364
1451
  }
1365
1452
 
1366
- const channelName = `discussions.thread.${threadId}`
1367
-
1368
1453
  if (activeThreadChannel === channelName) {
1369
1454
  return
1370
1455
  }
@@ -2291,28 +2376,38 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
2291
2376
  error.value = message
2292
2377
  }
2293
2378
 
2294
- const stopListeningToEchoReconnect = onEchoReconnected(() => {
2295
- const savedSpace = activeSpaceChannel
2296
- const savedThread = activeThreadChannel
2379
+ function rehydrateRealtimeSubscriptions(): void {
2380
+ const savedDesiredSpaceChannel = desiredSpaceChannel
2381
+ const savedDesiredThreadChannel = desiredThreadChannel
2382
+ const spaceId = extractRealtimeChannelId(savedDesiredSpaceChannel, DISCUSSION_SPACE_CHANNEL_PREFIX)
2383
+ const threadId = extractRealtimeChannelId(savedDesiredThreadChannel, DISCUSSION_THREAD_CHANNEL_PREFIX)
2297
2384
 
2298
2385
  activeSpaceChannel = null
2299
2386
  activeThreadChannel = null
2300
2387
 
2301
- if (savedSpace) {
2302
- const spaceId = savedSpace.replace('discussions.space.', '')
2388
+ if (spaceId) {
2303
2389
  subscribeToSpaceRealtime(spaceId)
2304
2390
  }
2305
2391
 
2306
- if (savedThread) {
2307
- const threadId = savedThread.replace('discussions.thread.', '')
2392
+ if (threadId) {
2308
2393
  subscribeToThreadRealtime(threadId)
2309
2394
  }
2395
+ }
2396
+
2397
+ const stopListeningToEchoReconnect = onEchoReconnected(() => {
2398
+ rehydrateRealtimeSubscriptions()
2399
+ })
2400
+ const stopListeningToEchoInitialized = onEchoInitialized?.(() => {
2401
+ rehydrateRealtimeSubscriptions()
2310
2402
  })
2311
2403
 
2312
2404
  onScopeDispose(() => {
2313
2405
  if (typeof stopListeningToEchoReconnect === 'function') {
2314
2406
  stopListeningToEchoReconnect()
2315
2407
  }
2408
+ if (typeof stopListeningToEchoInitialized === 'function') {
2409
+ stopListeningToEchoInitialized()
2410
+ }
2316
2411
  cleanupRealtimeChannels()
2317
2412
  })
2318
2413
 
@@ -267,6 +267,7 @@ export interface DiscussionStoreConfig {
267
267
  client: AxiosInstance
268
268
  getCurrentUserId: () => string | null
269
269
  getEcho: () => DiscussionRealtimeClientLike | null
270
+ onEchoInitialized?: (callback: () => void) => (() => void) | void
270
271
  onEchoReconnected: (callback: () => void) => (() => void) | void
271
272
  createLoadingState: () => DiscussionLoadingState
272
273
  logger?: Pick<Console, 'error'>