@codingfactory/socialkit-vue 0.6.0 → 0.7.2

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 +6 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/services/circles.d.ts.map +1 -1
  6. package/dist/services/circles.js +12 -1
  7. package/dist/services/circles.js.map +1 -1
  8. package/dist/services/gamification.d.ts +87 -0
  9. package/dist/services/gamification.d.ts.map +1 -0
  10. package/dist/services/gamification.js +263 -0
  11. package/dist/services/gamification.js.map +1 -0
  12. package/dist/stores/discussion.d.ts.map +1 -1
  13. package/dist/stores/discussion.js +59 -53
  14. package/dist/stores/discussion.js.map +1 -1
  15. package/dist/stores/gamification.d.ts +2875 -0
  16. package/dist/stores/gamification.d.ts.map +1 -0
  17. package/dist/stores/gamification.js +1136 -0
  18. package/dist/stores/gamification.js.map +1 -0
  19. package/dist/types/api.d.ts +1 -0
  20. package/dist/types/api.d.ts.map +1 -1
  21. package/dist/types/gamification.d.ts +267 -0
  22. package/dist/types/gamification.d.ts.map +1 -0
  23. package/dist/types/gamification.js +5 -0
  24. package/dist/types/gamification.js.map +1 -0
  25. package/dist/types/reputation.d.ts +55 -0
  26. package/dist/types/reputation.d.ts.map +1 -0
  27. package/dist/types/reputation.js +5 -0
  28. package/dist/types/reputation.js.map +1 -0
  29. package/package.json +4 -2
  30. package/src/index.ts +65 -0
  31. package/src/services/circles.ts +13 -1
  32. package/src/services/gamification.ts +432 -0
  33. package/src/stores/discussion.ts +75 -54
  34. package/src/stores/gamification.ts +1565 -0
  35. package/src/types/api.ts +1 -0
  36. package/src/types/gamification.ts +286 -0
  37. package/src/types/reputation.ts +78 -0
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Configurable gamification service for SocialKit-powered frontends.
3
+ */
4
+
5
+ import type { ApiService } from './api.js'
6
+ import type {
7
+ Achievement,
8
+ GamificationNotification,
9
+ LeaderboardEntry,
10
+ PointTransaction,
11
+ Quest,
12
+ Reward,
13
+ UserStats,
14
+ } from '../types/gamification.js'
15
+ import type {
16
+ ReputationPrivilege,
17
+ ReputationSnapshot,
18
+ UserStreakStatus,
19
+ } from '../types/reputation.js'
20
+
21
+ type LeaderboardPeriodInput = 'daily' | 'weekly' | 'monthly' | 'all-time' | 'all_time'
22
+
23
+ interface CursorCollectionResponse<TItem> {
24
+ data: TItem[]
25
+ next_cursor?: string
26
+ }
27
+
28
+ interface QuestCompletionResponse {
29
+ quest_id: string
30
+ completed_at: string
31
+ points_awarded: number
32
+ }
33
+
34
+ export interface GamificationWebSocketMessageEventLike {
35
+ data: unknown
36
+ }
37
+
38
+ export interface GamificationWebSocketLike {
39
+ readonly OPEN: number
40
+ readyState: number
41
+ onopen: ((event: unknown) => void) | null
42
+ onmessage: ((event: GamificationWebSocketMessageEventLike) => void) | null
43
+ onclose: ((event: unknown) => void) | null
44
+ onerror: ((event: unknown) => void) | null
45
+ close(): void
46
+ }
47
+
48
+ export type GamificationWebSocketFactory = (url: string) => GamificationWebSocketLike
49
+
50
+ export interface GamificationServiceLogger {
51
+ debug: (...args: unknown[]) => void
52
+ error: (...args: unknown[]) => void
53
+ }
54
+
55
+ export interface GamificationServiceConfig {
56
+ client: ApiService
57
+ webSocketUrl?: string | null
58
+ webSocketFactory?: GamificationWebSocketFactory
59
+ reconnectIntervalMs?: number
60
+ logger?: GamificationServiceLogger
61
+ debug?: boolean
62
+ }
63
+
64
+ export interface GamificationServiceInstance {
65
+ getUserStats(userId?: string): Promise<UserStats>
66
+ getUserReputation(userId?: string): Promise<UserStats>
67
+ getUserStreak(): Promise<UserStreakStatus>
68
+ getTransactions(options?: { limit?: number; offset?: number }): Promise<PointTransaction[]>
69
+ getReputationSnapshots(options?: { period?: 'week' | 'month' | 'year' }): Promise<ReputationSnapshot[]>
70
+ getPrivilegesCatalog(): Promise<ReputationPrivilege[]>
71
+ getAchievements(options?: { status?: 'locked' | 'unlocked' | 'all'; page?: number }): Promise<CursorCollectionResponse<Achievement>>
72
+ claimAchievement(achievementId: string): Promise<Achievement>
73
+ getQuests(options?: { status?: 'available' | 'in_progress' | 'completed' | 'all'; page?: number }): Promise<CursorCollectionResponse<Quest>>
74
+ claimQuestReward(questId: string): Promise<QuestCompletionResponse>
75
+ abandonQuest(questId: string): Promise<void>
76
+ getLeaderboard(options?: {
77
+ period?: LeaderboardPeriodInput
78
+ limit?: number
79
+ offset?: number
80
+ spaceId?: string
81
+ }): Promise<LeaderboardEntry[]>
82
+ getUserRank(): Promise<{ rank: number; percentile: number }>
83
+ getRewards(options?: {
84
+ status?: 'available' | 'redeemed' | 'all'
85
+ page?: number
86
+ }): Promise<CursorCollectionResponse<Reward>>
87
+ redeemReward(rewardId: string): Promise<Reward>
88
+ connectWebSocket(): GamificationWebSocketLike | null
89
+ disconnectWebSocket(): void
90
+ subscribe(event: string, callback: (data: GamificationNotification) => void): void
91
+ unsubscribe(event: string, callback: (data: GamificationNotification) => void): void
92
+ }
93
+
94
+ const createQuestCompletionIdempotencyKey = (questId: string): string => {
95
+ const randomPart = typeof globalThis.crypto?.randomUUID === 'function'
96
+ ? globalThis.crypto.randomUUID()
97
+ : Math.random().toString(36).slice(2)
98
+
99
+ return `quest-complete:${questId}:${randomPart}`
100
+ }
101
+
102
+ function createDefaultWebSocket(url: string): GamificationWebSocketLike {
103
+ if (typeof WebSocket === 'undefined') {
104
+ throw new Error('WebSocket is unavailable in this environment')
105
+ }
106
+
107
+ return new WebSocket(url) as unknown as GamificationWebSocketLike
108
+ }
109
+
110
+ class GamificationService implements GamificationServiceInstance {
111
+ private ws: GamificationWebSocketLike | null = null
112
+ private reconnectInterval: ReturnType<typeof setInterval> | null = null
113
+ private listeners: Map<string, Set<(data: GamificationNotification) => void>> = new Map()
114
+
115
+ public constructor(
116
+ private readonly client: ApiService,
117
+ private readonly webSocketUrl: string | null,
118
+ private readonly webSocketFactory: GamificationWebSocketFactory,
119
+ private readonly reconnectIntervalMs: number,
120
+ private readonly logger: GamificationServiceLogger,
121
+ private readonly debug: boolean,
122
+ ) {}
123
+
124
+ public async getUserStats(userId?: string): Promise<UserStats> {
125
+ const endpoint = userId
126
+ ? `/v1/gamification/users/${userId}/reputation`
127
+ : '/v1/gamification/users/me/reputation'
128
+ const response = await this.client.get<UserStats>(endpoint)
129
+ return response.data
130
+ }
131
+
132
+ public async getUserReputation(userId?: string): Promise<UserStats> {
133
+ return this.getUserStats(userId)
134
+ }
135
+
136
+ public async getUserStreak(): Promise<UserStreakStatus> {
137
+ const response = await this.client.get<UserStreakStatus>('/v1/gamification/users/me/streak')
138
+ return response.data
139
+ }
140
+
141
+ public async getTransactions(options?: {
142
+ limit?: number
143
+ offset?: number
144
+ }): Promise<PointTransaction[]> {
145
+ const params = new URLSearchParams()
146
+ if (options?.limit) {
147
+ params.append('limit', options.limit.toString())
148
+ }
149
+ if (options?.offset) {
150
+ params.append('offset', options.offset.toString())
151
+ }
152
+
153
+ const query = params.toString()
154
+ const url = query.length > 0
155
+ ? `/v1/gamification/users/me/transactions?${query}`
156
+ : '/v1/gamification/users/me/transactions'
157
+
158
+ const response = await this.client.get<PointTransaction[]>(url)
159
+ return response.data
160
+ }
161
+
162
+ public async getReputationSnapshots(options?: {
163
+ period?: 'week' | 'month' | 'year'
164
+ }): Promise<ReputationSnapshot[]> {
165
+ const params = new URLSearchParams()
166
+ if (options?.period) {
167
+ params.append('period', options.period)
168
+ }
169
+
170
+ const query = params.toString()
171
+ const url = query.length > 0
172
+ ? `/v1/gamification/users/me/reputation/snapshots?${query}`
173
+ : '/v1/gamification/users/me/reputation/snapshots'
174
+
175
+ const response = await this.client.get<ReputationSnapshot[]>(url)
176
+ return response.data
177
+ }
178
+
179
+ public async getPrivilegesCatalog(): Promise<ReputationPrivilege[]> {
180
+ const response = await this.client.get<ReputationPrivilege[]>('/v1/gamification/privileges')
181
+ return response.data
182
+ }
183
+
184
+ public async getAchievements(options?: {
185
+ status?: 'locked' | 'unlocked' | 'all'
186
+ page?: number
187
+ }): Promise<CursorCollectionResponse<Achievement>> {
188
+ const params = new URLSearchParams()
189
+ if (options?.status && options.status !== 'all') {
190
+ params.append('status', options.status)
191
+ }
192
+ if (options?.page) {
193
+ params.append('page', options.page.toString())
194
+ }
195
+
196
+ const response = await this.client.get<CursorCollectionResponse<Achievement>>(
197
+ `/v1/gamification/achievements?${params.toString()}`
198
+ )
199
+ return response.data
200
+ }
201
+
202
+ public async claimAchievement(achievementId: string): Promise<Achievement> {
203
+ const response = await this.client.post<Achievement>(`/v1/gamification/achievements/${achievementId}/claim`)
204
+ return response.data
205
+ }
206
+
207
+ public async getQuests(options?: {
208
+ status?: 'available' | 'in_progress' | 'completed' | 'all'
209
+ page?: number
210
+ }): Promise<CursorCollectionResponse<Quest>> {
211
+ const params = new URLSearchParams()
212
+ if (options?.status && options.status !== 'all') {
213
+ params.append('status', options.status)
214
+ }
215
+ if (options?.page) {
216
+ params.append('page', options.page.toString())
217
+ }
218
+
219
+ const endpoint = options?.status === 'available'
220
+ ? '/v1/gamification/quests/available'
221
+ : '/v1/gamification/quests/my-quests'
222
+ const response = await this.client.get<CursorCollectionResponse<Quest>>(`${endpoint}?${params.toString()}`)
223
+ return response.data
224
+ }
225
+
226
+ public async claimQuestReward(questId: string): Promise<QuestCompletionResponse> {
227
+ const idempotencyKey = createQuestCompletionIdempotencyKey(questId)
228
+ const response = await this.client.post<QuestCompletionResponse>(
229
+ `/v1/gamification/quests/${questId}/complete`,
230
+ undefined,
231
+ {
232
+ headers: {
233
+ 'Idempotency-Key': idempotencyKey,
234
+ },
235
+ },
236
+ )
237
+ return response.data
238
+ }
239
+
240
+ public async abandonQuest(questId: string): Promise<void> {
241
+ await this.client.delete(`/v1/gamification/quests/${questId}/abandon`)
242
+ }
243
+
244
+ public async getLeaderboard(options?: {
245
+ period?: LeaderboardPeriodInput
246
+ limit?: number
247
+ offset?: number
248
+ spaceId?: string
249
+ }): Promise<LeaderboardEntry[]> {
250
+ const params = new URLSearchParams()
251
+ if (options?.period) {
252
+ const normalizedPeriod = options.period === 'all_time' ? 'all-time' : options.period
253
+ params.append('period', normalizedPeriod)
254
+ }
255
+ if (options?.limit) {
256
+ params.append('limit', options.limit.toString())
257
+ }
258
+ if (options?.offset) {
259
+ params.append('offset', options.offset.toString())
260
+ }
261
+ if (options?.spaceId) {
262
+ params.append('space_id', options.spaceId)
263
+ }
264
+
265
+ const query = params.toString()
266
+ const url = query.length > 0
267
+ ? `/v1/gamification/leaderboard?${query}`
268
+ : '/v1/gamification/leaderboard'
269
+
270
+ const response = await this.client.get<LeaderboardEntry[]>(url)
271
+ return response.data
272
+ }
273
+
274
+ public async getUserRank(): Promise<{ rank: number; percentile: number }> {
275
+ const response = await this.client.get<{ rank: number; percentile: number }>('/v1/gamification/users/me/rank')
276
+ return response.data
277
+ }
278
+
279
+ public async getRewards(options?: {
280
+ status?: 'available' | 'redeemed' | 'all'
281
+ page?: number
282
+ }): Promise<CursorCollectionResponse<Reward>> {
283
+ const params = new URLSearchParams()
284
+ if (options?.status && options.status !== 'all') {
285
+ params.append('status', options.status)
286
+ }
287
+ if (options?.page) {
288
+ params.append('page', options.page.toString())
289
+ }
290
+
291
+ const query = params.toString()
292
+ const endpoint = query.length > 0
293
+ ? `/v1/gamification/rewards?${query}`
294
+ : '/v1/gamification/rewards'
295
+
296
+ const response = await this.client.get<Reward[]>(endpoint)
297
+ return { data: response.data }
298
+ }
299
+
300
+ public async redeemReward(rewardId: string): Promise<Reward> {
301
+ const response = await this.client.post<Reward>(`/v1/gamification/rewards/${rewardId}/redeem`)
302
+ return response.data
303
+ }
304
+
305
+ public connectWebSocket(): GamificationWebSocketLike | null {
306
+ if (this.ws && this.ws.readyState === this.ws.OPEN) {
307
+ return this.ws
308
+ }
309
+
310
+ const socketUrl = typeof this.webSocketUrl === 'string' && this.webSocketUrl.length > 0
311
+ ? this.webSocketUrl
312
+ : null
313
+ if (socketUrl === null) {
314
+ return null
315
+ }
316
+
317
+ try {
318
+ this.ws = this.webSocketFactory(`${socketUrl}/gamification`)
319
+
320
+ this.ws.onopen = () => {
321
+ this.debugLog('Gamification WebSocket connected')
322
+ if (this.reconnectInterval) {
323
+ clearInterval(this.reconnectInterval)
324
+ this.reconnectInterval = null
325
+ }
326
+ }
327
+
328
+ this.ws.onmessage = (event) => {
329
+ try {
330
+ const payload = typeof event.data === 'string'
331
+ ? JSON.parse(event.data)
332
+ : event.data
333
+ this.handleWebSocketMessage(payload as GamificationNotification)
334
+ } catch (error) {
335
+ this.logger.error('Failed to parse WebSocket message:', error)
336
+ }
337
+ }
338
+
339
+ this.ws.onclose = () => {
340
+ this.debugLog('Gamification WebSocket disconnected')
341
+ this.attemptReconnect()
342
+ }
343
+
344
+ this.ws.onerror = (error) => {
345
+ this.logger.error('Gamification WebSocket error:', error)
346
+ }
347
+
348
+ return this.ws
349
+ } catch (error) {
350
+ this.logger.error('Failed to connect WebSocket:', error)
351
+ return null
352
+ }
353
+ }
354
+
355
+ public disconnectWebSocket(): void {
356
+ if (this.ws) {
357
+ this.ws.close()
358
+ this.ws = null
359
+ }
360
+ if (this.reconnectInterval) {
361
+ clearInterval(this.reconnectInterval)
362
+ this.reconnectInterval = null
363
+ }
364
+ this.listeners.clear()
365
+ }
366
+
367
+ public subscribe(event: string, callback: (data: GamificationNotification) => void): void {
368
+ if (!this.listeners.has(event)) {
369
+ this.listeners.set(event, new Set())
370
+ }
371
+ this.listeners.get(event)?.add(callback)
372
+ }
373
+
374
+ public unsubscribe(event: string, callback: (data: GamificationNotification) => void): void {
375
+ this.listeners.get(event)?.delete(callback)
376
+ }
377
+
378
+ private debugLog(message: string, ...args: unknown[]): void {
379
+ if (!this.debug) {
380
+ return
381
+ }
382
+
383
+ this.logger.debug(message, ...args)
384
+ }
385
+
386
+ private attemptReconnect(): void {
387
+ if (this.reconnectInterval) {
388
+ return
389
+ }
390
+
391
+ this.reconnectInterval = setInterval(() => {
392
+ this.debugLog('Gamification WebSocket reconnect attempt')
393
+ this.connectWebSocket()
394
+ }, this.reconnectIntervalMs)
395
+ }
396
+
397
+ private handleWebSocketMessage(data: GamificationNotification): void {
398
+ const listeners = this.listeners.get(data.type)
399
+ if (listeners) {
400
+ listeners.forEach((listener) => {
401
+ listener(data)
402
+ })
403
+ }
404
+
405
+ const updateListeners = this.listeners.get('update')
406
+ if (updateListeners) {
407
+ updateListeners.forEach((listener) => {
408
+ listener(data)
409
+ })
410
+ }
411
+ }
412
+ }
413
+
414
+ export function createGamificationService(config: GamificationServiceConfig): GamificationServiceInstance {
415
+ const {
416
+ client,
417
+ webSocketUrl = null,
418
+ webSocketFactory = createDefaultWebSocket,
419
+ reconnectIntervalMs = 5000,
420
+ logger = console,
421
+ debug = false,
422
+ } = config
423
+
424
+ return new GamificationService(
425
+ client,
426
+ webSocketUrl,
427
+ webSocketFactory,
428
+ reconnectIntervalMs,
429
+ logger,
430
+ debug,
431
+ )
432
+ }
@@ -241,6 +241,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
241
241
  const threads = ref<Thread[]>([])
242
242
  const currentThread = ref<Thread | null>(null)
243
243
  const replies = ref<Reply[]>([])
244
+ const currentReplySort = ref<'best' | 'top' | 'new' | 'controversial'>('best')
244
245
 
245
246
  const spacesLoadingState = createLoadingState()
246
247
  const threadsLoadingState = createLoadingState()
@@ -950,6 +951,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
950
951
  }
951
952
 
952
953
  const filteredReplyBatch = filterOpeningReplies(loadedReplyBatch)
954
+ currentReplySort.value = sortBy
953
955
 
954
956
  if (cursor) {
955
957
  replies.value = mergeUniqueById(replies.value, filteredReplyBatch)
@@ -998,6 +1000,70 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
998
1000
  }
999
1001
  }
1000
1002
 
1003
+ function insertTopLevelReply(reply: Reply): void {
1004
+ if (currentReplySort.value === 'new') {
1005
+ replies.value.unshift(reply)
1006
+ return
1007
+ }
1008
+
1009
+ replies.value.push(reply)
1010
+ }
1011
+
1012
+ function insertReplyIntoActiveThread(reply: Reply, parentReplyId?: string | null): boolean {
1013
+ const existingReplyIndex = replies.value.findIndex((candidate) => candidate.id === reply.id)
1014
+
1015
+ if (existingReplyIndex !== -1) {
1016
+ const existingReply = replies.value[existingReplyIndex]
1017
+ if (existingReply) {
1018
+ replies.value[existingReplyIndex] = {
1019
+ ...existingReply,
1020
+ ...reply,
1021
+ }
1022
+ }
1023
+
1024
+ return false
1025
+ }
1026
+
1027
+ if (parentReplyId) {
1028
+ const parentIndex = replies.value.findIndex((candidate) => candidate.id === parentReplyId)
1029
+ if (parentIndex !== -1) {
1030
+ const parent = replies.value[parentIndex]
1031
+ const parentDepth = parent?.depth ?? parent?.display_depth ?? 0
1032
+
1033
+ reply.depth = parentDepth + 1
1034
+ reply.display_depth = parentDepth + 1
1035
+ reply.parent_reply_id = parentReplyId
1036
+
1037
+ let insertIndex = parentIndex + 1
1038
+ while (insertIndex < replies.value.length) {
1039
+ const nextReply = replies.value[insertIndex]
1040
+ const nextDepth = nextReply?.depth ?? nextReply?.display_depth ?? 0
1041
+ if (nextDepth <= parentDepth) {
1042
+ break
1043
+ }
1044
+ insertIndex += 1
1045
+ }
1046
+
1047
+ replies.value.splice(insertIndex, 0, reply)
1048
+
1049
+ if (parent) {
1050
+ parent.reply_count = (parent.reply_count ?? 0) + 1
1051
+ parent.children_count = (parent.children_count ?? 0) + 1
1052
+ }
1053
+
1054
+ return true
1055
+ }
1056
+
1057
+ replies.value.push(reply)
1058
+ return true
1059
+ }
1060
+
1061
+ reply.depth = 0
1062
+ reply.display_depth = 0
1063
+ insertTopLevelReply(reply)
1064
+ return true
1065
+ }
1066
+
1001
1067
  async function createThread(
1002
1068
  spaceSlug: string,
1003
1069
  input: CreateThreadInput
@@ -1153,52 +1219,7 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1153
1219
  let didInsertReply = false
1154
1220
 
1155
1221
  if (newReply) {
1156
- const existingReplyIndex = replies.value.findIndex((reply) => reply.id === newReply.id)
1157
-
1158
- if (existingReplyIndex !== -1) {
1159
- const existingReply = replies.value[existingReplyIndex]
1160
- if (existingReply) {
1161
- replies.value[existingReplyIndex] = {
1162
- ...existingReply,
1163
- ...newReply,
1164
- }
1165
- }
1166
- } else if (input.parent_id) {
1167
- const parentIndex = replies.value.findIndex((reply) => reply.id === input.parent_id)
1168
- if (parentIndex !== -1) {
1169
- const parent = replies.value[parentIndex]
1170
- const parentDepth = parent?.depth ?? parent?.display_depth ?? 0
1171
- newReply.depth = parentDepth + 1
1172
- newReply.display_depth = parentDepth + 1
1173
- newReply.parent_reply_id = input.parent_id
1174
-
1175
- let insertIndex = parentIndex + 1
1176
- while (insertIndex < replies.value.length) {
1177
- const reply = replies.value[insertIndex]
1178
- const replyDepth = reply?.depth ?? reply?.display_depth ?? 0
1179
- if (replyDepth <= parentDepth) {
1180
- break
1181
- }
1182
- insertIndex += 1
1183
- }
1184
-
1185
- replies.value.splice(insertIndex, 0, newReply)
1186
- didInsertReply = true
1187
-
1188
- if (parent) {
1189
- parent.reply_count = (parent.reply_count ?? 0) + 1
1190
- parent.children_count = (parent.children_count ?? 0) + 1
1191
- }
1192
- } else {
1193
- replies.value.push(newReply)
1194
- didInsertReply = true
1195
- }
1196
- } else {
1197
- newReply.depth = 0
1198
- newReply.display_depth = 0
1199
- replies.value.push(newReply)
1200
- didInsertReply = true
1201
- }
1222
+ didInsertReply = insertReplyIntoActiveThread(newReply, input.parent_id)
1202
1223
  }
1203
1224
 
1204
1225
  if (didInsertReply && currentThread.value?.id === threadId) {
@@ -1394,15 +1415,15 @@ export function createDiscussionStoreDefinition(config: DiscussionStoreConfig) {
1394
1415
  return normalized
1395
1416
  })()
1396
1417
 
1397
- const already = replies.value.some((reply) => reply.id === payload.id)
1398
- if (!already) {
1399
- replies.value.push(normalizedPayload)
1418
+ const didInsertReply = insertReplyIntoActiveThread(
1419
+ normalizedPayload,
1420
+ normalizedPayload.parent_reply_id ?? null
1421
+ )
1400
1422
 
1401
- if (currentThread.value && payload.id && !realtimeCountedReplyIds.has(payload.id)) {
1402
- realtimeCountedReplyIds.add(payload.id)
1403
- currentThread.value.reply_count = (currentThread.value.reply_count || 0) + 1
1404
- currentThread.value.last_activity_at = payload.created_at ?? new Date().toISOString()
1405
- }
1423
+ if (didInsertReply && currentThread.value && payload.id && !realtimeCountedReplyIds.has(payload.id)) {
1424
+ realtimeCountedReplyIds.add(payload.id)
1425
+ currentThread.value.reply_count = (currentThread.value.reply_count || 0) + 1
1426
+ currentThread.value.last_activity_at = payload.created_at ?? new Date().toISOString()
1406
1427
  }
1407
1428
  }
1408
1429