@codingfactory/socialkit-vue 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/services/circles.d.ts.map +1 -1
- package/dist/services/circles.js +6 -2
- package/dist/services/circles.js.map +1 -1
- package/dist/services/echo.d.ts +90 -0
- package/dist/services/echo.d.ts.map +1 -0
- package/dist/services/echo.js +712 -0
- package/dist/services/echo.js.map +1 -0
- package/dist/types/realtime.d.ts +85 -0
- package/dist/types/realtime.d.ts.map +1 -0
- package/dist/types/realtime.js +5 -0
- package/dist/types/realtime.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +41 -0
- package/src/services/circles.ts +8 -2
- package/src/services/echo.ts +1072 -0
- package/src/types/realtime.ts +95 -0
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurable Echo/Reverb client for SocialKit-powered frontends.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { EchoConfig } from '../types/api.js'
|
|
6
|
+
import type {
|
|
7
|
+
ConnectionStatus,
|
|
8
|
+
LiveCircleCountUpdatedEvent,
|
|
9
|
+
PresenceStatus,
|
|
10
|
+
PresenceStatusChangedEvent,
|
|
11
|
+
ProfileActivityCreatedEvent,
|
|
12
|
+
ProfileStatsUpdatedEvent,
|
|
13
|
+
StoryTrayUpdatedEvent,
|
|
14
|
+
TutorialCommentAddedEvent,
|
|
15
|
+
TutorialProgressUpdatedEvent
|
|
16
|
+
} from '../types/realtime.js'
|
|
17
|
+
|
|
18
|
+
export interface EchoChannelLike {
|
|
19
|
+
listen(event: string, callback: (data: unknown) => void): EchoChannelLike
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EchoInstanceLike {
|
|
23
|
+
connector?: {
|
|
24
|
+
pusher?: {
|
|
25
|
+
connection?: PusherConnectionBindings
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
private(channel: string): EchoChannelLike
|
|
29
|
+
join(channel: string): EchoChannelLike
|
|
30
|
+
leave(channel: string): void
|
|
31
|
+
disconnect(): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EchoConstructor<TInstance extends EchoInstanceLike = EchoInstanceLike> {
|
|
35
|
+
new (...args: any[]): TInstance
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type UnknownRecord = Record<string, unknown>
|
|
39
|
+
type EchoWindow = Window & {
|
|
40
|
+
Echo?: EchoInstanceLike | null
|
|
41
|
+
Pusher?: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PusherConnectionEventHandler {
|
|
45
|
+
(data?: unknown): void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PusherConnectionBindings {
|
|
49
|
+
bind(event: string, handler: PusherConnectionEventHandler): void
|
|
50
|
+
state?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface PusherConnectorInterface {
|
|
54
|
+
pusher: {
|
|
55
|
+
connection: PusherConnectionBindings
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EchoServiceLogger {
|
|
60
|
+
debug(message: string, context?: unknown): void
|
|
61
|
+
warn(message: string, context?: unknown): void
|
|
62
|
+
error(message: string, context?: unknown): void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface EchoConnectionConfig extends EchoConfig {
|
|
66
|
+
wsPath?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EchoDisconnectOptions {
|
|
70
|
+
preventReconnect?: boolean
|
|
71
|
+
skipStatusUpdate?: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface EchoReconnectOptions {
|
|
75
|
+
resetBackoff?: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ProfileUpdatesCallbacks {
|
|
79
|
+
onStatsUpdated?: (data: ProfileStatsUpdatedEvent) => void
|
|
80
|
+
onActivityCreated?: (data: ProfileActivityCreatedEvent) => void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface StoryTrayCallbacks {
|
|
84
|
+
onStoryTrayUpdated?: (data: StoryTrayUpdatedEvent) => void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface EchoServiceConfig {
|
|
88
|
+
Echo: EchoConstructor
|
|
89
|
+
pusher?: unknown
|
|
90
|
+
getToken: () => string | null
|
|
91
|
+
resolveConnectionConfig: (token: string) => EchoConnectionConfig
|
|
92
|
+
resolveTenantScope?: () => string
|
|
93
|
+
initializedEvent?: string
|
|
94
|
+
logger?: Partial<EchoServiceLogger>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface EchoServiceInstance<TInstance extends EchoInstanceLike = EchoInstanceLike> {
|
|
98
|
+
initializeEcho(): TInstance | null
|
|
99
|
+
getEcho(): TInstance | null
|
|
100
|
+
disconnectEcho(options?: EchoDisconnectOptions): void
|
|
101
|
+
reconnectEcho(options?: EchoReconnectOptions): void
|
|
102
|
+
getConnectionStatus(): ConnectionStatus
|
|
103
|
+
onConnectionStatusChange(callback: (status: ConnectionStatus) => void): () => void
|
|
104
|
+
onEchoReconnected(callback: () => void): () => void
|
|
105
|
+
subscribeToPresenceStatus(callback: (event: PresenceStatusChangedEvent) => void): void
|
|
106
|
+
unsubscribeFromPresenceStatus(): void
|
|
107
|
+
subscribeToLiveCircleCount(
|
|
108
|
+
circleId: string,
|
|
109
|
+
callback: (event: LiveCircleCountUpdatedEvent) => void
|
|
110
|
+
): () => void
|
|
111
|
+
subscribeToTutorialComments(
|
|
112
|
+
tutorialId: string,
|
|
113
|
+
callback: (event: TutorialCommentAddedEvent) => void
|
|
114
|
+
): () => void
|
|
115
|
+
subscribeToTutorialProgress(
|
|
116
|
+
tutorialId: string,
|
|
117
|
+
callback: (event: TutorialProgressUpdatedEvent) => void
|
|
118
|
+
): () => void
|
|
119
|
+
subscribeToProfileUpdates(userId: string, callbacks: ProfileUpdatesCallbacks): void
|
|
120
|
+
unsubscribeFromProfileUpdates(userId: string): void
|
|
121
|
+
subscribeToStoryTray(callbacks: StoryTrayCallbacks): boolean
|
|
122
|
+
unsubscribeFromStoryTray(): void
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const TRANSIENT_PUSHER_CLOSE_CODE = 1006
|
|
126
|
+
const CONNECTION_STATE_RECONCILE_DELAYS_MS = [0, 50, 250, 1000, 5000] as const
|
|
127
|
+
const FAST_RECONNECT_RETRY_COUNT = 10
|
|
128
|
+
const MAX_RECONNECT_DELAY_MS = 30000
|
|
129
|
+
const STEADY_RECONNECT_DELAY_MS = 60000
|
|
130
|
+
const HEALTH_CHECK_INTERVAL_MS = 45000
|
|
131
|
+
const DISCONNECT_SUPPRESSION_RESET_MS = 2000
|
|
132
|
+
|
|
133
|
+
const noopLogger: EchoServiceLogger = {
|
|
134
|
+
debug: () => undefined,
|
|
135
|
+
warn: () => undefined,
|
|
136
|
+
error: () => undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getBrowserWindow(): EchoWindow | null {
|
|
140
|
+
return typeof window === 'undefined' ? null : window as unknown as EchoWindow
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getBrowserDocument(): Document | null {
|
|
144
|
+
return typeof document === 'undefined' ? null : document
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ensurePusherGlobal(pusher?: unknown): void {
|
|
148
|
+
const liveWindow = getBrowserWindow()
|
|
149
|
+
if (liveWindow && pusher !== undefined) {
|
|
150
|
+
;(liveWindow as { Pusher?: unknown }).Pusher = pusher
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isRecord(value: unknown): value is UnknownRecord {
|
|
155
|
+
return typeof value === 'object' && value !== null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasString(obj: UnknownRecord, key: string): boolean {
|
|
159
|
+
return typeof obj[key] === 'string'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function getPresenceField(payload: UnknownRecord, snakeKey: string, camelKey: string): string | null {
|
|
163
|
+
const snakeValue = payload[snakeKey]
|
|
164
|
+
if (typeof snakeValue === 'string' && snakeValue.trim().length > 0) {
|
|
165
|
+
return snakeValue
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const camelValue = payload[camelKey]
|
|
169
|
+
if (typeof camelValue === 'string' && camelValue.trim().length > 0) {
|
|
170
|
+
return camelValue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizePresenceStatus(rawStatus: unknown): PresenceStatus | null {
|
|
177
|
+
if (typeof rawStatus !== 'string') {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const normalizedStatus = rawStatus.trim().toLowerCase()
|
|
182
|
+
if (
|
|
183
|
+
normalizedStatus === 'online'
|
|
184
|
+
|| normalizedStatus === 'idle'
|
|
185
|
+
|| normalizedStatus === 'dnd'
|
|
186
|
+
|| normalizedStatus === 'offline'
|
|
187
|
+
) {
|
|
188
|
+
return normalizedStatus
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizePresenceStatusChangedPayload(
|
|
195
|
+
data: unknown,
|
|
196
|
+
resolveTenantScope: () => string
|
|
197
|
+
): PresenceStatusChangedEvent | null {
|
|
198
|
+
if (!isRecord(data)) {
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const userId = getPresenceField(data, 'user_id', 'userId')
|
|
203
|
+
const status = normalizePresenceStatus(data.status)
|
|
204
|
+
if (!userId || !status) {
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const payload: PresenceStatusChangedEvent = {
|
|
209
|
+
user_id: userId,
|
|
210
|
+
tenant_scope: getPresenceField(data, 'tenant_scope', 'tenantScope') ?? resolveTenantScope(),
|
|
211
|
+
status,
|
|
212
|
+
ts: getPresenceField(data, 'ts', 'timestamp') ?? new Date().toISOString()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const tenantIdValue = data.tenant_id ?? data.tenantId
|
|
216
|
+
if (typeof tenantIdValue === 'string') {
|
|
217
|
+
payload.tenant_id = tenantIdValue
|
|
218
|
+
} else if (tenantIdValue === null) {
|
|
219
|
+
payload.tenant_id = null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const lastSeenValue = data.last_seen_at ?? data.lastSeenAt
|
|
223
|
+
if (typeof lastSeenValue === 'string') {
|
|
224
|
+
payload.last_seen_at = lastSeenValue
|
|
225
|
+
} else if (lastSeenValue === null) {
|
|
226
|
+
payload.last_seen_at = null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return payload
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function isValidPresenceStatusChangedEvent(data: unknown): data is PresenceStatusChangedEvent {
|
|
233
|
+
if (!isRecord(data)) {
|
|
234
|
+
return false
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return hasString(data, 'user_id')
|
|
238
|
+
&& (data.status === 'online' || data.status === 'idle' || data.status === 'dnd' || data.status === 'offline')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function isValidLiveCircleCountUpdatedEvent(data: unknown): data is LiveCircleCountUpdatedEvent {
|
|
242
|
+
if (!isRecord(data)) {
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return hasString(data, 'circle_id') && typeof data.count === 'number'
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function isValidTutorialCommentAddedEvent(data: unknown): data is TutorialCommentAddedEvent {
|
|
250
|
+
if (!isRecord(data) || !hasString(data, 'tutorial_id') || !isRecord(data.comment)) {
|
|
251
|
+
return false
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const comment = data.comment
|
|
255
|
+
return hasString(comment, 'id')
|
|
256
|
+
&& hasString(comment, 'author_id')
|
|
257
|
+
&& hasString(comment, 'author_name')
|
|
258
|
+
&& hasString(comment, 'body')
|
|
259
|
+
&& hasString(comment, 'created_at')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function isValidTutorialProgressUpdatedEvent(data: unknown): data is TutorialProgressUpdatedEvent {
|
|
263
|
+
if (!isRecord(data)) {
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (
|
|
268
|
+
!hasString(data, 'tutorial_id')
|
|
269
|
+
|| !hasString(data, 'user_id')
|
|
270
|
+
|| !hasString(data, 'current_step_id')
|
|
271
|
+
|| typeof data.progress_percent !== 'number'
|
|
272
|
+
|| typeof data.is_completed !== 'boolean'
|
|
273
|
+
|| !hasString(data, 'updated_at')
|
|
274
|
+
) {
|
|
275
|
+
return false
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!Array.isArray(data.completed_steps)) {
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return data.completed_steps.every((stepId: unknown) => typeof stepId === 'string')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function isValidProfileStatsUpdatedEvent(data: unknown): data is ProfileStatsUpdatedEvent {
|
|
286
|
+
if (!isRecord(data) || !isRecord(data.stats)) {
|
|
287
|
+
return false
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const stats = data.stats
|
|
291
|
+
return typeof stats.followers_count === 'number' && typeof stats.following_count === 'number'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function isValidProfileActivityCreatedEvent(data: unknown): data is ProfileActivityCreatedEvent {
|
|
295
|
+
if (!isRecord(data) || !isRecord(data.activity)) {
|
|
296
|
+
return false
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const activity = data.activity
|
|
300
|
+
return hasString(activity, 'id') && hasString(activity, 'activity_type') && hasString(activity, 'created_at')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function isValidStoryTrayUpdatedEvent(data: unknown): data is StoryTrayUpdatedEvent {
|
|
304
|
+
if (!isRecord(data)) {
|
|
305
|
+
return false
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return hasString(data, 'story_id') && hasString(data, 'author_id')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getPusherErrorCode(error: unknown): number | null {
|
|
312
|
+
if (!isRecord(error) || !isRecord(error.data)) {
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return typeof error.data.code === 'number' ? error.data.code : null
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function resolvePusherConnection(echo: EchoInstanceLike | null): PusherConnectionBindings | null {
|
|
320
|
+
const connector = echo?.connector as PusherConnectorInterface | undefined
|
|
321
|
+
return connector?.pusher?.connection ?? null
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function guardedListen<T>(
|
|
325
|
+
channel: EchoChannelLike,
|
|
326
|
+
event: string,
|
|
327
|
+
validator: (data: unknown) => data is T,
|
|
328
|
+
callback: (data: T) => void,
|
|
329
|
+
label: string,
|
|
330
|
+
logger: EchoServiceLogger
|
|
331
|
+
): (data: unknown) => void {
|
|
332
|
+
const handler = (data: unknown): void => {
|
|
333
|
+
if (!validator(data)) {
|
|
334
|
+
logger.warn(`Dropping malformed ${label} event`, data)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
callback(data)
|
|
340
|
+
} catch (error) {
|
|
341
|
+
logger.error(`Error handling ${label} event`, error)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
channel.listen(event, handler)
|
|
346
|
+
return handler
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function createEchoService<TInstance extends EchoInstanceLike = EchoInstanceLike>(
|
|
350
|
+
config: EchoServiceConfig & {
|
|
351
|
+
Echo: EchoConstructor<TInstance>
|
|
352
|
+
}
|
|
353
|
+
): EchoServiceInstance<TInstance> {
|
|
354
|
+
const logger: EchoServiceLogger = {
|
|
355
|
+
...noopLogger,
|
|
356
|
+
...config.logger
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const resolveTenantScope = (): string => {
|
|
360
|
+
const tenantScope = config.resolveTenantScope?.()
|
|
361
|
+
return typeof tenantScope === 'string' && tenantScope.length > 0 ? tenantScope : 'global'
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let echoInstance: TInstance | null = null
|
|
365
|
+
let suppressNextDisconnectEvent = false
|
|
366
|
+
let suppressDisconnectResetTimeoutId: ReturnType<typeof setTimeout> | null = null
|
|
367
|
+
let connectionStateReconcileTimeoutId: ReturnType<typeof setTimeout> | null = null
|
|
368
|
+
let connectionStateReconcileAttempt = 0
|
|
369
|
+
let connectionStatus: ConnectionStatus = 'disconnected'
|
|
370
|
+
const connectionCallbacks = new Set<(status: ConnectionStatus) => void>()
|
|
371
|
+
const reconnectCallbacks = new Set<() => void>()
|
|
372
|
+
let hasConnectedAtLeastOnce = false
|
|
373
|
+
let reconnectAttempts = 0
|
|
374
|
+
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null
|
|
375
|
+
let healthCheckIntervalId: ReturnType<typeof setInterval> | null = null
|
|
376
|
+
let connectionGeneration = 0
|
|
377
|
+
|
|
378
|
+
function clearConnectionStateReconcile(): void {
|
|
379
|
+
if (connectionStateReconcileTimeoutId) {
|
|
380
|
+
clearTimeout(connectionStateReconcileTimeoutId)
|
|
381
|
+
connectionStateReconcileTimeoutId = null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
connectionStateReconcileAttempt = 0
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function updateConnectionStatus(status: ConnectionStatus): void {
|
|
388
|
+
if (connectionStatus === status) {
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const previousStatus = connectionStatus
|
|
393
|
+
connectionStatus = status
|
|
394
|
+
logger.debug(`Connection status changed to ${status}`)
|
|
395
|
+
|
|
396
|
+
for (const callback of connectionCallbacks) {
|
|
397
|
+
callback(status)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (status !== 'connected') {
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const recoveredConnection = hasConnectedAtLeastOnce && previousStatus !== 'connected'
|
|
405
|
+
hasConnectedAtLeastOnce = true
|
|
406
|
+
|
|
407
|
+
if (!recoveredConnection) {
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
for (const callback of reconnectCallbacks) {
|
|
412
|
+
callback()
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function scheduleConnectionStateReconcile(connection: PusherConnectionBindings): void {
|
|
417
|
+
clearConnectionStateReconcile()
|
|
418
|
+
|
|
419
|
+
const reconcile = (): void => {
|
|
420
|
+
connectionStateReconcileTimeoutId = null
|
|
421
|
+
|
|
422
|
+
if (getConnectionStatus() !== 'connecting') {
|
|
423
|
+
clearConnectionStateReconcile()
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (connection.state === 'connected') {
|
|
428
|
+
updateConnectionStatus('connected')
|
|
429
|
+
clearConnectionStateReconcile()
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
connectionStateReconcileAttempt += 1
|
|
434
|
+
const nextDelay = CONNECTION_STATE_RECONCILE_DELAYS_MS[connectionStateReconcileAttempt]
|
|
435
|
+
if (typeof nextDelay !== 'number') {
|
|
436
|
+
clearConnectionStateReconcile()
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
connectionStateReconcileTimeoutId = setTimeout(reconcile, nextDelay)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
connectionStateReconcileTimeoutId = setTimeout(reconcile, CONNECTION_STATE_RECONCILE_DELAYS_MS[0])
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function stopHealthCheck(): void {
|
|
447
|
+
if (healthCheckIntervalId) {
|
|
448
|
+
clearInterval(healthCheckIntervalId)
|
|
449
|
+
healthCheckIntervalId = null
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function startHealthCheck(): void {
|
|
454
|
+
if (healthCheckIntervalId) {
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
healthCheckIntervalId = setInterval(() => {
|
|
459
|
+
const liveDocument = getBrowserDocument()
|
|
460
|
+
if (liveDocument?.hidden) {
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const status = getConnectionStatus()
|
|
465
|
+
if (status === 'disconnected' || status === 'error') {
|
|
466
|
+
logger.debug('Health check: connection is down, triggering reconnect')
|
|
467
|
+
reconnectAttempts = 0
|
|
468
|
+
scheduleReconnect()
|
|
469
|
+
}
|
|
470
|
+
}, HEALTH_CHECK_INTERVAL_MS)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function scheduleReconnect(): void {
|
|
474
|
+
const delay = reconnectAttempts < FAST_RECONNECT_RETRY_COUNT
|
|
475
|
+
? Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS)
|
|
476
|
+
: STEADY_RECONNECT_DELAY_MS
|
|
477
|
+
|
|
478
|
+
reconnectAttempts += 1
|
|
479
|
+
logger.debug(`Scheduling reconnect attempt ${reconnectAttempts} in ${delay}ms`)
|
|
480
|
+
|
|
481
|
+
if (reconnectTimeoutId) {
|
|
482
|
+
clearTimeout(reconnectTimeoutId)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
reconnectTimeoutId = setTimeout(() => {
|
|
486
|
+
logger.debug(`Reconnect attempt ${reconnectAttempts}`)
|
|
487
|
+
reconnectEcho()
|
|
488
|
+
}, delay)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function initializeEcho(): TInstance | null {
|
|
492
|
+
connectionGeneration += 1
|
|
493
|
+
const thisGeneration = connectionGeneration
|
|
494
|
+
|
|
495
|
+
if (suppressDisconnectResetTimeoutId) {
|
|
496
|
+
clearTimeout(suppressDisconnectResetTimeoutId)
|
|
497
|
+
suppressDisconnectResetTimeoutId = null
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
clearConnectionStateReconcile()
|
|
501
|
+
|
|
502
|
+
const token = config.getToken()
|
|
503
|
+
if (!token) {
|
|
504
|
+
logger.warn('No auth token available, skipping initialization')
|
|
505
|
+
return null
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (echoInstance) {
|
|
509
|
+
logger.debug('Already initialized')
|
|
510
|
+
return echoInstance
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
ensurePusherGlobal(config.pusher)
|
|
514
|
+
|
|
515
|
+
logger.debug('Initializing connection to Reverb...')
|
|
516
|
+
|
|
517
|
+
const echoOptions = config.resolveConnectionConfig(token)
|
|
518
|
+
logger.debug('Resolved config', {
|
|
519
|
+
host: echoOptions.wsHost,
|
|
520
|
+
port: echoOptions.wsPort,
|
|
521
|
+
tls: echoOptions.forceTLS,
|
|
522
|
+
path: echoOptions.wsPath ?? '(default)',
|
|
523
|
+
authEndpoint: echoOptions.authEndpoint
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
echoInstance = new config.Echo(echoOptions) as TInstance
|
|
527
|
+
|
|
528
|
+
const liveWindow = getBrowserWindow()
|
|
529
|
+
if (liveWindow) {
|
|
530
|
+
;(liveWindow as unknown as { Echo?: TInstance | null }).Echo = echoInstance
|
|
531
|
+
if (config.initializedEvent) {
|
|
532
|
+
liveWindow.dispatchEvent(new CustomEvent(config.initializedEvent))
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const connection = resolvePusherConnection(echoInstance)
|
|
537
|
+
if (!connection) {
|
|
538
|
+
logger.error('Unable to access Pusher connection')
|
|
539
|
+
return echoInstance
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
connection.bind('connected', () => {
|
|
543
|
+
if (thisGeneration !== connectionGeneration) {
|
|
544
|
+
logger.debug('Ignoring stale connected event from old connection')
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
logger.debug('Connected to Reverb WebSocket server')
|
|
549
|
+
clearConnectionStateReconcile()
|
|
550
|
+
suppressNextDisconnectEvent = false
|
|
551
|
+
|
|
552
|
+
if (suppressDisconnectResetTimeoutId) {
|
|
553
|
+
clearTimeout(suppressDisconnectResetTimeoutId)
|
|
554
|
+
suppressDisconnectResetTimeoutId = null
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
updateConnectionStatus('connected')
|
|
558
|
+
reconnectAttempts = 0
|
|
559
|
+
|
|
560
|
+
if (reconnectTimeoutId) {
|
|
561
|
+
clearTimeout(reconnectTimeoutId)
|
|
562
|
+
reconnectTimeoutId = null
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
startHealthCheck()
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
connection.bind('disconnected', () => {
|
|
569
|
+
if (thisGeneration !== connectionGeneration) {
|
|
570
|
+
logger.debug('Ignoring stale disconnected event from old connection')
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (suppressNextDisconnectEvent) {
|
|
575
|
+
logger.debug('Skipping reconnect for intentional disconnect')
|
|
576
|
+
suppressNextDisconnectEvent = false
|
|
577
|
+
|
|
578
|
+
if (suppressDisconnectResetTimeoutId) {
|
|
579
|
+
clearTimeout(suppressDisconnectResetTimeoutId)
|
|
580
|
+
suppressDisconnectResetTimeoutId = null
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
logger.warn('Disconnected from Reverb WebSocket server')
|
|
587
|
+
updateConnectionStatus('disconnected')
|
|
588
|
+
scheduleReconnect()
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
connection.bind('error', (error?: unknown) => {
|
|
592
|
+
if (thisGeneration !== connectionGeneration) {
|
|
593
|
+
logger.debug('Ignoring stale error event from old connection')
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const errorCode = getPusherErrorCode(error)
|
|
598
|
+
if (errorCode === TRANSIENT_PUSHER_CLOSE_CODE) {
|
|
599
|
+
if (suppressNextDisconnectEvent) {
|
|
600
|
+
logger.debug('Ignoring PusherError 1006 during intentional disconnect')
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
logger.debug('Received PusherError 1006; treating as disconnected')
|
|
605
|
+
updateConnectionStatus('disconnected')
|
|
606
|
+
scheduleReconnect()
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
logger.error('Connection error', error)
|
|
611
|
+
updateConnectionStatus('error')
|
|
612
|
+
scheduleReconnect()
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
connection.bind('unavailable', () => {
|
|
616
|
+
if (thisGeneration !== connectionGeneration) {
|
|
617
|
+
logger.debug('Ignoring stale unavailable event from old connection')
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (suppressNextDisconnectEvent) {
|
|
622
|
+
logger.debug('Ignoring unavailable event during intentional disconnect')
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
logger.debug('Connection unavailable; scheduling reconnect')
|
|
627
|
+
updateConnectionStatus('disconnected')
|
|
628
|
+
scheduleReconnect()
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
connection.bind('connecting', () => {
|
|
632
|
+
if (thisGeneration !== connectionGeneration) {
|
|
633
|
+
logger.debug('Ignoring stale connecting event from old connection')
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
logger.debug('Connecting to Reverb WebSocket server')
|
|
638
|
+
updateConnectionStatus('connecting')
|
|
639
|
+
scheduleConnectionStateReconcile(connection)
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
updateConnectionStatus('connecting')
|
|
643
|
+
|
|
644
|
+
if (connection.state === 'connected') {
|
|
645
|
+
updateConnectionStatus('connected')
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
scheduleConnectionStateReconcile(connection)
|
|
649
|
+
|
|
650
|
+
return echoInstance
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function getEcho(): TInstance | null {
|
|
654
|
+
const liveWindow = getBrowserWindow()
|
|
655
|
+
|
|
656
|
+
if (!echoInstance && liveWindow?.Echo) {
|
|
657
|
+
echoInstance = liveWindow.Echo as TInstance
|
|
658
|
+
|
|
659
|
+
const liveConnection = resolvePusherConnection(echoInstance)
|
|
660
|
+
if (liveConnection?.state === 'connected') {
|
|
661
|
+
updateConnectionStatus('connected')
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return echoInstance
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function disconnectEcho(options: EchoDisconnectOptions = {}): void {
|
|
669
|
+
const preventReconnect = options.preventReconnect ?? true
|
|
670
|
+
|
|
671
|
+
if (!preventReconnect) {
|
|
672
|
+
suppressNextDisconnectEvent = false
|
|
673
|
+
if (suppressDisconnectResetTimeoutId) {
|
|
674
|
+
clearTimeout(suppressDisconnectResetTimeoutId)
|
|
675
|
+
suppressDisconnectResetTimeoutId = null
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
clearConnectionStateReconcile()
|
|
680
|
+
stopHealthCheck()
|
|
681
|
+
|
|
682
|
+
if (echoInstance) {
|
|
683
|
+
if (preventReconnect) {
|
|
684
|
+
suppressNextDisconnectEvent = true
|
|
685
|
+
|
|
686
|
+
if (suppressDisconnectResetTimeoutId) {
|
|
687
|
+
clearTimeout(suppressDisconnectResetTimeoutId)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
suppressDisconnectResetTimeoutId = setTimeout(() => {
|
|
691
|
+
suppressNextDisconnectEvent = false
|
|
692
|
+
suppressDisconnectResetTimeoutId = null
|
|
693
|
+
}, DISCONNECT_SUPPRESSION_RESET_MS)
|
|
694
|
+
|
|
695
|
+
if (reconnectTimeoutId) {
|
|
696
|
+
clearTimeout(reconnectTimeoutId)
|
|
697
|
+
reconnectTimeoutId = null
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
logger.debug('Disconnecting...')
|
|
702
|
+
echoInstance.disconnect()
|
|
703
|
+
echoInstance = null
|
|
704
|
+
|
|
705
|
+
const liveWindow = getBrowserWindow()
|
|
706
|
+
if (liveWindow) {
|
|
707
|
+
delete liveWindow.Echo
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (preventReconnect && !options.skipStatusUpdate) {
|
|
712
|
+
updateConnectionStatus('disconnected')
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function getConnectionStatus(): ConnectionStatus {
|
|
717
|
+
const liveState = resolvePusherConnection(getBrowserWindow()?.Echo ?? null)?.state
|
|
718
|
+
if (liveState === 'connected' && connectionStatus !== 'connected') {
|
|
719
|
+
updateConnectionStatus('connected')
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return connectionStatus
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function onConnectionStatusChange(callback: (status: ConnectionStatus) => void): () => void {
|
|
726
|
+
connectionCallbacks.add(callback)
|
|
727
|
+
|
|
728
|
+
return () => {
|
|
729
|
+
connectionCallbacks.delete(callback)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function onEchoReconnected(callback: () => void): () => void {
|
|
734
|
+
reconnectCallbacks.add(callback)
|
|
735
|
+
|
|
736
|
+
return () => {
|
|
737
|
+
reconnectCallbacks.delete(callback)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function subscribeToPresenceStatus(callback: (event: PresenceStatusChangedEvent) => void): void {
|
|
742
|
+
const echo = getEcho()
|
|
743
|
+
if (!echo) {
|
|
744
|
+
logger.warn('Cannot subscribe to presence status, not connected')
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const channelName = `online-users.${resolveTenantScope()}`
|
|
749
|
+
logger.debug(`Subscribing to ${channelName}`)
|
|
750
|
+
|
|
751
|
+
const channel = echo.join(channelName) as EchoChannelLike
|
|
752
|
+
const presenceEventNames = [
|
|
753
|
+
'.PresenceStatusChanged',
|
|
754
|
+
'PresenceStatusChanged',
|
|
755
|
+
'.presence.status.changed',
|
|
756
|
+
'presence.status.changed',
|
|
757
|
+
'.Presence.StatusChanged',
|
|
758
|
+
'Presence.StatusChanged',
|
|
759
|
+
'.Modules\\Presence\\Events\\UserPresenceStatusChanged',
|
|
760
|
+
'Modules\\Presence\\Events\\UserPresenceStatusChanged'
|
|
761
|
+
]
|
|
762
|
+
|
|
763
|
+
for (const eventName of presenceEventNames) {
|
|
764
|
+
const validator = (rawEvent: unknown): rawEvent is UnknownRecord => isRecord(rawEvent)
|
|
765
|
+
guardedListen(channel, eventName, validator, (rawEvent) => {
|
|
766
|
+
const event = normalizePresenceStatusChangedPayload(rawEvent, resolveTenantScope)
|
|
767
|
+
if (!event) {
|
|
768
|
+
logger.warn('Dropping malformed PresenceStatusChanged event', rawEvent)
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
logger.debug('Presence status event received', event)
|
|
773
|
+
callback(event)
|
|
774
|
+
}, 'PresenceStatusChanged', logger)
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function unsubscribeFromPresenceStatus(): void {
|
|
779
|
+
const echo = getEcho()
|
|
780
|
+
if (!echo) {
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const channelName = `online-users.${resolveTenantScope()}`
|
|
785
|
+
logger.debug(`Unsubscribing from ${channelName}`)
|
|
786
|
+
echo.leave(channelName)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function subscribeToLiveCircleCount(
|
|
790
|
+
circleId: string,
|
|
791
|
+
callback: (event: LiveCircleCountUpdatedEvent) => void
|
|
792
|
+
): () => void {
|
|
793
|
+
const echo = getEcho()
|
|
794
|
+
if (!echo) {
|
|
795
|
+
logger.warn('Cannot subscribe to live circle count, not connected')
|
|
796
|
+
return () => undefined
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const channelName = `live-circle.${circleId}`
|
|
800
|
+
logger.debug(`Subscribing to ${channelName}`)
|
|
801
|
+
|
|
802
|
+
const channel = echo.private(channelName) as EchoChannelLike
|
|
803
|
+
const handler = guardedListen(
|
|
804
|
+
channel,
|
|
805
|
+
'.LiveCircleCountUpdated',
|
|
806
|
+
isValidLiveCircleCountUpdatedEvent,
|
|
807
|
+
(data) => {
|
|
808
|
+
logger.debug('Live circle count updated event received', data)
|
|
809
|
+
callback(data)
|
|
810
|
+
},
|
|
811
|
+
'LiveCircleCountUpdated',
|
|
812
|
+
logger
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
let cleanedUp = false
|
|
816
|
+
return () => {
|
|
817
|
+
if (cleanedUp) {
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
cleanedUp = true
|
|
822
|
+
const stoppableChannel = channel as EchoChannelLike & {
|
|
823
|
+
stopListening?: (event: string, callback?: (data: unknown) => void) => unknown
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (typeof stoppableChannel.stopListening !== 'function') {
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
stoppableChannel.stopListening('.LiveCircleCountUpdated', handler)
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function subscribeToTutorialComments(
|
|
835
|
+
tutorialId: string,
|
|
836
|
+
callback: (event: TutorialCommentAddedEvent) => void
|
|
837
|
+
): () => void {
|
|
838
|
+
const echo = getEcho() ?? initializeEcho()
|
|
839
|
+
if (!echo) {
|
|
840
|
+
logger.warn('Cannot subscribe to tutorial comments, not connected')
|
|
841
|
+
return () => undefined
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const channelName = `tutorial.${tutorialId}.comments`
|
|
845
|
+
logger.debug(`Subscribing to ${channelName}`)
|
|
846
|
+
|
|
847
|
+
const channel = echo.private(channelName) as EchoChannelLike
|
|
848
|
+
const handler = guardedListen(
|
|
849
|
+
channel,
|
|
850
|
+
'.comment.added',
|
|
851
|
+
isValidTutorialCommentAddedEvent,
|
|
852
|
+
(data) => {
|
|
853
|
+
logger.debug('Tutorial comment added event received', data)
|
|
854
|
+
callback(data)
|
|
855
|
+
},
|
|
856
|
+
'comment.added',
|
|
857
|
+
logger
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
let cleanedUp = false
|
|
861
|
+
return () => {
|
|
862
|
+
if (cleanedUp) {
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
cleanedUp = true
|
|
867
|
+
const stoppableChannel = channel as EchoChannelLike & {
|
|
868
|
+
stopListening?: (event: string, callback?: (data: unknown) => void) => unknown
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (typeof stoppableChannel.stopListening !== 'function') {
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
stoppableChannel.stopListening('.comment.added', handler)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function subscribeToTutorialProgress(
|
|
880
|
+
tutorialId: string,
|
|
881
|
+
callback: (event: TutorialProgressUpdatedEvent) => void
|
|
882
|
+
): () => void {
|
|
883
|
+
const echo = getEcho() ?? initializeEcho()
|
|
884
|
+
if (!echo) {
|
|
885
|
+
logger.warn('Cannot subscribe to tutorial progress, not connected')
|
|
886
|
+
return () => undefined
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const channelName = `tutorial.${tutorialId}.progress`
|
|
890
|
+
logger.debug(`Subscribing to ${channelName}`)
|
|
891
|
+
|
|
892
|
+
const channel = echo.private(channelName) as EchoChannelLike
|
|
893
|
+
channel.listen('pusher:subscription_error', (error: unknown) => {
|
|
894
|
+
logger.warn(`Channel subscription failed for ${channelName}`, error)
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
const handler = guardedListen(
|
|
898
|
+
channel,
|
|
899
|
+
'.progress.updated',
|
|
900
|
+
isValidTutorialProgressUpdatedEvent,
|
|
901
|
+
(data) => {
|
|
902
|
+
logger.debug('Tutorial progress updated event received', data)
|
|
903
|
+
callback(data)
|
|
904
|
+
},
|
|
905
|
+
'progress.updated',
|
|
906
|
+
logger
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
let cleanedUp = false
|
|
910
|
+
return () => {
|
|
911
|
+
if (cleanedUp) {
|
|
912
|
+
return
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
cleanedUp = true
|
|
916
|
+
const stoppableChannel = channel as EchoChannelLike & {
|
|
917
|
+
stopListening?: (event: string, callback?: (data: unknown) => void) => unknown
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (typeof stoppableChannel.stopListening !== 'function') {
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
stoppableChannel.stopListening('.progress.updated', handler)
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function subscribeToProfileUpdates(userId: string, callbacks: ProfileUpdatesCallbacks): void {
|
|
929
|
+
const echo = getEcho()
|
|
930
|
+
if (!echo) {
|
|
931
|
+
logger.warn('Cannot subscribe to profile updates, not connected')
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const channelName = `profiles.${userId}`
|
|
936
|
+
logger.debug(`Subscribing to ${channelName}`)
|
|
937
|
+
|
|
938
|
+
const channel = echo.private(channelName) as EchoChannelLike
|
|
939
|
+
|
|
940
|
+
if (callbacks.onStatsUpdated) {
|
|
941
|
+
const onStatsUpdated = callbacks.onStatsUpdated
|
|
942
|
+
guardedListen(
|
|
943
|
+
channel,
|
|
944
|
+
'.Profiles.StatsUpdated',
|
|
945
|
+
isValidProfileStatsUpdatedEvent,
|
|
946
|
+
(data) => {
|
|
947
|
+
logger.debug('Profile stats updated event received', data)
|
|
948
|
+
onStatsUpdated(data)
|
|
949
|
+
},
|
|
950
|
+
'Profiles.StatsUpdated',
|
|
951
|
+
logger
|
|
952
|
+
)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (callbacks.onActivityCreated) {
|
|
956
|
+
const onActivityCreated = callbacks.onActivityCreated
|
|
957
|
+
guardedListen(
|
|
958
|
+
channel,
|
|
959
|
+
'.Profiles.ActivityCreated',
|
|
960
|
+
isValidProfileActivityCreatedEvent,
|
|
961
|
+
(data) => {
|
|
962
|
+
logger.debug('Profile activity created event received', data)
|
|
963
|
+
onActivityCreated(data)
|
|
964
|
+
},
|
|
965
|
+
'Profiles.ActivityCreated',
|
|
966
|
+
logger
|
|
967
|
+
)
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function unsubscribeFromProfileUpdates(userId: string): void {
|
|
972
|
+
const echo = getEcho()
|
|
973
|
+
if (!echo) {
|
|
974
|
+
return
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const channelName = `profiles.${userId}`
|
|
978
|
+
logger.debug(`Unsubscribing from ${channelName}`)
|
|
979
|
+
echo.leave(channelName)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function subscribeToStoryTray(callbacks: StoryTrayCallbacks): boolean {
|
|
983
|
+
const echo = getEcho() ?? initializeEcho()
|
|
984
|
+
if (!echo) {
|
|
985
|
+
logger.warn('Cannot subscribe to story tray channel, not connected')
|
|
986
|
+
return false
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const channelName = `story-tray.${resolveTenantScope()}`
|
|
990
|
+
logger.debug(`Subscribing to ${channelName}`)
|
|
991
|
+
|
|
992
|
+
const channel = echo.private(channelName) as EchoChannelLike
|
|
993
|
+
|
|
994
|
+
if (callbacks.onStoryTrayUpdated) {
|
|
995
|
+
const onStoryTrayUpdated = callbacks.onStoryTrayUpdated
|
|
996
|
+
guardedListen(
|
|
997
|
+
channel,
|
|
998
|
+
'.StoryTrayUpdated',
|
|
999
|
+
isValidStoryTrayUpdatedEvent,
|
|
1000
|
+
(data) => {
|
|
1001
|
+
logger.debug('Story tray updated event received', data)
|
|
1002
|
+
onStoryTrayUpdated(data)
|
|
1003
|
+
},
|
|
1004
|
+
'StoryTrayUpdated',
|
|
1005
|
+
logger
|
|
1006
|
+
)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return true
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function unsubscribeFromStoryTray(): void {
|
|
1013
|
+
const echo = getEcho()
|
|
1014
|
+
if (!echo) {
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const channelName = `story-tray.${resolveTenantScope()}`
|
|
1019
|
+
logger.debug(`Unsubscribing from ${channelName}`)
|
|
1020
|
+
echo.leave(channelName)
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function reconnectEcho(options: EchoReconnectOptions = {}): void {
|
|
1024
|
+
if (options.resetBackoff) {
|
|
1025
|
+
reconnectAttempts = 0
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (reconnectTimeoutId) {
|
|
1029
|
+
clearTimeout(reconnectTimeoutId)
|
|
1030
|
+
reconnectTimeoutId = null
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
updateConnectionStatus('connecting')
|
|
1034
|
+
|
|
1035
|
+
if (echoInstance) {
|
|
1036
|
+
logger.debug('Disconnecting before reconnect')
|
|
1037
|
+
disconnectEcho({ preventReconnect: true, skipStatusUpdate: true })
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
try {
|
|
1041
|
+
echoInstance = initializeEcho()
|
|
1042
|
+
if (!echoInstance) {
|
|
1043
|
+
logger.error('Reconnect failed: initializeEcho returned null')
|
|
1044
|
+
updateConnectionStatus('error')
|
|
1045
|
+
scheduleReconnect()
|
|
1046
|
+
}
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
logger.error('Reconnect failed', error)
|
|
1049
|
+
updateConnectionStatus('error')
|
|
1050
|
+
scheduleReconnect()
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return {
|
|
1055
|
+
initializeEcho,
|
|
1056
|
+
getEcho,
|
|
1057
|
+
disconnectEcho,
|
|
1058
|
+
reconnectEcho,
|
|
1059
|
+
getConnectionStatus,
|
|
1060
|
+
onConnectionStatusChange,
|
|
1061
|
+
onEchoReconnected,
|
|
1062
|
+
subscribeToPresenceStatus,
|
|
1063
|
+
unsubscribeFromPresenceStatus,
|
|
1064
|
+
subscribeToLiveCircleCount,
|
|
1065
|
+
subscribeToTutorialComments,
|
|
1066
|
+
subscribeToTutorialProgress,
|
|
1067
|
+
subscribeToProfileUpdates,
|
|
1068
|
+
unsubscribeFromProfileUpdates,
|
|
1069
|
+
subscribeToStoryTray,
|
|
1070
|
+
unsubscribeFromStoryTray
|
|
1071
|
+
}
|
|
1072
|
+
}
|