@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.
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/services/circles.d.ts.map +1 -1
- package/dist/services/circles.js +12 -1
- package/dist/services/circles.js.map +1 -1
- package/dist/services/gamification.d.ts +87 -0
- package/dist/services/gamification.d.ts.map +1 -0
- package/dist/services/gamification.js +263 -0
- package/dist/services/gamification.js.map +1 -0
- package/dist/stores/discussion.d.ts.map +1 -1
- package/dist/stores/discussion.js +59 -53
- package/dist/stores/discussion.js.map +1 -1
- package/dist/stores/gamification.d.ts +2875 -0
- package/dist/stores/gamification.d.ts.map +1 -0
- package/dist/stores/gamification.js +1136 -0
- package/dist/stores/gamification.js.map +1 -0
- package/dist/types/api.d.ts +1 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/gamification.d.ts +267 -0
- package/dist/types/gamification.d.ts.map +1 -0
- package/dist/types/gamification.js +5 -0
- package/dist/types/gamification.js.map +1 -0
- package/dist/types/reputation.d.ts +55 -0
- package/dist/types/reputation.d.ts.map +1 -0
- package/dist/types/reputation.js +5 -0
- package/dist/types/reputation.js.map +1 -0
- package/package.json +4 -2
- package/src/index.ts +65 -0
- package/src/services/circles.ts +13 -1
- package/src/services/gamification.ts +432 -0
- package/src/stores/discussion.ts +75 -54
- package/src/stores/gamification.ts +1565 -0
- package/src/types/api.ts +1 -0
- package/src/types/gamification.ts +286 -0
- 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
|
+
}
|
package/src/stores/discussion.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1398
|
-
|
|
1399
|
-
|
|
1418
|
+
const didInsertReply = insertReplyIntoActiveThread(
|
|
1419
|
+
normalizedPayload,
|
|
1420
|
+
normalizedPayload.parent_reply_id ?? null
|
|
1421
|
+
)
|
|
1400
1422
|
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
|