@codingfactory/socialkit-vue 0.3.1 → 0.5.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 +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/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/stores/discussion.d.ts +2017 -0
- package/dist/stores/discussion.d.ts.map +1 -0
- package/dist/stores/discussion.js +1857 -0
- package/dist/stores/discussion.js.map +1 -0
- package/dist/types/discussion.d.ts +242 -0
- package/dist/types/discussion.d.ts.map +1 -0
- package/dist/types/discussion.js +5 -0
- package/dist/types/discussion.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 +72 -0
- package/src/services/echo.ts +1072 -0
- package/src/stores/discussion.ts +2249 -0
- package/src/types/discussion.ts +274 -0
- package/src/types/realtime.ts +95 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurable Echo/Reverb client for SocialKit-powered frontends.
|
|
3
|
+
*/
|
|
4
|
+
const TRANSIENT_PUSHER_CLOSE_CODE = 1006;
|
|
5
|
+
const CONNECTION_STATE_RECONCILE_DELAYS_MS = [0, 50, 250, 1000, 5000];
|
|
6
|
+
const FAST_RECONNECT_RETRY_COUNT = 10;
|
|
7
|
+
const MAX_RECONNECT_DELAY_MS = 30000;
|
|
8
|
+
const STEADY_RECONNECT_DELAY_MS = 60000;
|
|
9
|
+
const HEALTH_CHECK_INTERVAL_MS = 45000;
|
|
10
|
+
const DISCONNECT_SUPPRESSION_RESET_MS = 2000;
|
|
11
|
+
const noopLogger = {
|
|
12
|
+
debug: () => undefined,
|
|
13
|
+
warn: () => undefined,
|
|
14
|
+
error: () => undefined
|
|
15
|
+
};
|
|
16
|
+
function getBrowserWindow() {
|
|
17
|
+
return typeof window === 'undefined' ? null : window;
|
|
18
|
+
}
|
|
19
|
+
function getBrowserDocument() {
|
|
20
|
+
return typeof document === 'undefined' ? null : document;
|
|
21
|
+
}
|
|
22
|
+
function ensurePusherGlobal(pusher) {
|
|
23
|
+
const liveWindow = getBrowserWindow();
|
|
24
|
+
if (liveWindow && pusher !== undefined) {
|
|
25
|
+
;
|
|
26
|
+
liveWindow.Pusher = pusher;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return typeof value === 'object' && value !== null;
|
|
31
|
+
}
|
|
32
|
+
function hasString(obj, key) {
|
|
33
|
+
return typeof obj[key] === 'string';
|
|
34
|
+
}
|
|
35
|
+
function getPresenceField(payload, snakeKey, camelKey) {
|
|
36
|
+
const snakeValue = payload[snakeKey];
|
|
37
|
+
if (typeof snakeValue === 'string' && snakeValue.trim().length > 0) {
|
|
38
|
+
return snakeValue;
|
|
39
|
+
}
|
|
40
|
+
const camelValue = payload[camelKey];
|
|
41
|
+
if (typeof camelValue === 'string' && camelValue.trim().length > 0) {
|
|
42
|
+
return camelValue;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function normalizePresenceStatus(rawStatus) {
|
|
47
|
+
if (typeof rawStatus !== 'string') {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const normalizedStatus = rawStatus.trim().toLowerCase();
|
|
51
|
+
if (normalizedStatus === 'online'
|
|
52
|
+
|| normalizedStatus === 'idle'
|
|
53
|
+
|| normalizedStatus === 'dnd'
|
|
54
|
+
|| normalizedStatus === 'offline') {
|
|
55
|
+
return normalizedStatus;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function normalizePresenceStatusChangedPayload(data, resolveTenantScope) {
|
|
60
|
+
if (!isRecord(data)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const userId = getPresenceField(data, 'user_id', 'userId');
|
|
64
|
+
const status = normalizePresenceStatus(data.status);
|
|
65
|
+
if (!userId || !status) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const payload = {
|
|
69
|
+
user_id: userId,
|
|
70
|
+
tenant_scope: getPresenceField(data, 'tenant_scope', 'tenantScope') ?? resolveTenantScope(),
|
|
71
|
+
status,
|
|
72
|
+
ts: getPresenceField(data, 'ts', 'timestamp') ?? new Date().toISOString()
|
|
73
|
+
};
|
|
74
|
+
const tenantIdValue = data.tenant_id ?? data.tenantId;
|
|
75
|
+
if (typeof tenantIdValue === 'string') {
|
|
76
|
+
payload.tenant_id = tenantIdValue;
|
|
77
|
+
}
|
|
78
|
+
else if (tenantIdValue === null) {
|
|
79
|
+
payload.tenant_id = null;
|
|
80
|
+
}
|
|
81
|
+
const lastSeenValue = data.last_seen_at ?? data.lastSeenAt;
|
|
82
|
+
if (typeof lastSeenValue === 'string') {
|
|
83
|
+
payload.last_seen_at = lastSeenValue;
|
|
84
|
+
}
|
|
85
|
+
else if (lastSeenValue === null) {
|
|
86
|
+
payload.last_seen_at = null;
|
|
87
|
+
}
|
|
88
|
+
return payload;
|
|
89
|
+
}
|
|
90
|
+
export function isValidPresenceStatusChangedEvent(data) {
|
|
91
|
+
if (!isRecord(data)) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return hasString(data, 'user_id')
|
|
95
|
+
&& (data.status === 'online' || data.status === 'idle' || data.status === 'dnd' || data.status === 'offline');
|
|
96
|
+
}
|
|
97
|
+
export function isValidLiveCircleCountUpdatedEvent(data) {
|
|
98
|
+
if (!isRecord(data)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return hasString(data, 'circle_id') && typeof data.count === 'number';
|
|
102
|
+
}
|
|
103
|
+
export function isValidTutorialCommentAddedEvent(data) {
|
|
104
|
+
if (!isRecord(data) || !hasString(data, 'tutorial_id') || !isRecord(data.comment)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const comment = data.comment;
|
|
108
|
+
return hasString(comment, 'id')
|
|
109
|
+
&& hasString(comment, 'author_id')
|
|
110
|
+
&& hasString(comment, 'author_name')
|
|
111
|
+
&& hasString(comment, 'body')
|
|
112
|
+
&& hasString(comment, 'created_at');
|
|
113
|
+
}
|
|
114
|
+
export function isValidTutorialProgressUpdatedEvent(data) {
|
|
115
|
+
if (!isRecord(data)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
if (!hasString(data, 'tutorial_id')
|
|
119
|
+
|| !hasString(data, 'user_id')
|
|
120
|
+
|| !hasString(data, 'current_step_id')
|
|
121
|
+
|| typeof data.progress_percent !== 'number'
|
|
122
|
+
|| typeof data.is_completed !== 'boolean'
|
|
123
|
+
|| !hasString(data, 'updated_at')) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(data.completed_steps)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return data.completed_steps.every((stepId) => typeof stepId === 'string');
|
|
130
|
+
}
|
|
131
|
+
export function isValidProfileStatsUpdatedEvent(data) {
|
|
132
|
+
if (!isRecord(data) || !isRecord(data.stats)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const stats = data.stats;
|
|
136
|
+
return typeof stats.followers_count === 'number' && typeof stats.following_count === 'number';
|
|
137
|
+
}
|
|
138
|
+
export function isValidProfileActivityCreatedEvent(data) {
|
|
139
|
+
if (!isRecord(data) || !isRecord(data.activity)) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const activity = data.activity;
|
|
143
|
+
return hasString(activity, 'id') && hasString(activity, 'activity_type') && hasString(activity, 'created_at');
|
|
144
|
+
}
|
|
145
|
+
export function isValidStoryTrayUpdatedEvent(data) {
|
|
146
|
+
if (!isRecord(data)) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
return hasString(data, 'story_id') && hasString(data, 'author_id');
|
|
150
|
+
}
|
|
151
|
+
function getPusherErrorCode(error) {
|
|
152
|
+
if (!isRecord(error) || !isRecord(error.data)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return typeof error.data.code === 'number' ? error.data.code : null;
|
|
156
|
+
}
|
|
157
|
+
function resolvePusherConnection(echo) {
|
|
158
|
+
const connector = echo?.connector;
|
|
159
|
+
return connector?.pusher?.connection ?? null;
|
|
160
|
+
}
|
|
161
|
+
function guardedListen(channel, event, validator, callback, label, logger) {
|
|
162
|
+
const handler = (data) => {
|
|
163
|
+
if (!validator(data)) {
|
|
164
|
+
logger.warn(`Dropping malformed ${label} event`, data);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
callback(data);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
logger.error(`Error handling ${label} event`, error);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
channel.listen(event, handler);
|
|
175
|
+
return handler;
|
|
176
|
+
}
|
|
177
|
+
export function createEchoService(config) {
|
|
178
|
+
const logger = {
|
|
179
|
+
...noopLogger,
|
|
180
|
+
...config.logger
|
|
181
|
+
};
|
|
182
|
+
const resolveTenantScope = () => {
|
|
183
|
+
const tenantScope = config.resolveTenantScope?.();
|
|
184
|
+
return typeof tenantScope === 'string' && tenantScope.length > 0 ? tenantScope : 'global';
|
|
185
|
+
};
|
|
186
|
+
let echoInstance = null;
|
|
187
|
+
let suppressNextDisconnectEvent = false;
|
|
188
|
+
let suppressDisconnectResetTimeoutId = null;
|
|
189
|
+
let connectionStateReconcileTimeoutId = null;
|
|
190
|
+
let connectionStateReconcileAttempt = 0;
|
|
191
|
+
let connectionStatus = 'disconnected';
|
|
192
|
+
const connectionCallbacks = new Set();
|
|
193
|
+
const reconnectCallbacks = new Set();
|
|
194
|
+
let hasConnectedAtLeastOnce = false;
|
|
195
|
+
let reconnectAttempts = 0;
|
|
196
|
+
let reconnectTimeoutId = null;
|
|
197
|
+
let healthCheckIntervalId = null;
|
|
198
|
+
let connectionGeneration = 0;
|
|
199
|
+
function clearConnectionStateReconcile() {
|
|
200
|
+
if (connectionStateReconcileTimeoutId) {
|
|
201
|
+
clearTimeout(connectionStateReconcileTimeoutId);
|
|
202
|
+
connectionStateReconcileTimeoutId = null;
|
|
203
|
+
}
|
|
204
|
+
connectionStateReconcileAttempt = 0;
|
|
205
|
+
}
|
|
206
|
+
function updateConnectionStatus(status) {
|
|
207
|
+
if (connectionStatus === status) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const previousStatus = connectionStatus;
|
|
211
|
+
connectionStatus = status;
|
|
212
|
+
logger.debug(`Connection status changed to ${status}`);
|
|
213
|
+
for (const callback of connectionCallbacks) {
|
|
214
|
+
callback(status);
|
|
215
|
+
}
|
|
216
|
+
if (status !== 'connected') {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const recoveredConnection = hasConnectedAtLeastOnce && previousStatus !== 'connected';
|
|
220
|
+
hasConnectedAtLeastOnce = true;
|
|
221
|
+
if (!recoveredConnection) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
for (const callback of reconnectCallbacks) {
|
|
225
|
+
callback();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function scheduleConnectionStateReconcile(connection) {
|
|
229
|
+
clearConnectionStateReconcile();
|
|
230
|
+
const reconcile = () => {
|
|
231
|
+
connectionStateReconcileTimeoutId = null;
|
|
232
|
+
if (getConnectionStatus() !== 'connecting') {
|
|
233
|
+
clearConnectionStateReconcile();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (connection.state === 'connected') {
|
|
237
|
+
updateConnectionStatus('connected');
|
|
238
|
+
clearConnectionStateReconcile();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
connectionStateReconcileAttempt += 1;
|
|
242
|
+
const nextDelay = CONNECTION_STATE_RECONCILE_DELAYS_MS[connectionStateReconcileAttempt];
|
|
243
|
+
if (typeof nextDelay !== 'number') {
|
|
244
|
+
clearConnectionStateReconcile();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
connectionStateReconcileTimeoutId = setTimeout(reconcile, nextDelay);
|
|
248
|
+
};
|
|
249
|
+
connectionStateReconcileTimeoutId = setTimeout(reconcile, CONNECTION_STATE_RECONCILE_DELAYS_MS[0]);
|
|
250
|
+
}
|
|
251
|
+
function stopHealthCheck() {
|
|
252
|
+
if (healthCheckIntervalId) {
|
|
253
|
+
clearInterval(healthCheckIntervalId);
|
|
254
|
+
healthCheckIntervalId = null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function startHealthCheck() {
|
|
258
|
+
if (healthCheckIntervalId) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
healthCheckIntervalId = setInterval(() => {
|
|
262
|
+
const liveDocument = getBrowserDocument();
|
|
263
|
+
if (liveDocument?.hidden) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const status = getConnectionStatus();
|
|
267
|
+
if (status === 'disconnected' || status === 'error') {
|
|
268
|
+
logger.debug('Health check: connection is down, triggering reconnect');
|
|
269
|
+
reconnectAttempts = 0;
|
|
270
|
+
scheduleReconnect();
|
|
271
|
+
}
|
|
272
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
273
|
+
}
|
|
274
|
+
function scheduleReconnect() {
|
|
275
|
+
const delay = reconnectAttempts < FAST_RECONNECT_RETRY_COUNT
|
|
276
|
+
? Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS)
|
|
277
|
+
: STEADY_RECONNECT_DELAY_MS;
|
|
278
|
+
reconnectAttempts += 1;
|
|
279
|
+
logger.debug(`Scheduling reconnect attempt ${reconnectAttempts} in ${delay}ms`);
|
|
280
|
+
if (reconnectTimeoutId) {
|
|
281
|
+
clearTimeout(reconnectTimeoutId);
|
|
282
|
+
}
|
|
283
|
+
reconnectTimeoutId = setTimeout(() => {
|
|
284
|
+
logger.debug(`Reconnect attempt ${reconnectAttempts}`);
|
|
285
|
+
reconnectEcho();
|
|
286
|
+
}, delay);
|
|
287
|
+
}
|
|
288
|
+
function initializeEcho() {
|
|
289
|
+
connectionGeneration += 1;
|
|
290
|
+
const thisGeneration = connectionGeneration;
|
|
291
|
+
if (suppressDisconnectResetTimeoutId) {
|
|
292
|
+
clearTimeout(suppressDisconnectResetTimeoutId);
|
|
293
|
+
suppressDisconnectResetTimeoutId = null;
|
|
294
|
+
}
|
|
295
|
+
clearConnectionStateReconcile();
|
|
296
|
+
const token = config.getToken();
|
|
297
|
+
if (!token) {
|
|
298
|
+
logger.warn('No auth token available, skipping initialization');
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
if (echoInstance) {
|
|
302
|
+
logger.debug('Already initialized');
|
|
303
|
+
return echoInstance;
|
|
304
|
+
}
|
|
305
|
+
ensurePusherGlobal(config.pusher);
|
|
306
|
+
logger.debug('Initializing connection to Reverb...');
|
|
307
|
+
const echoOptions = config.resolveConnectionConfig(token);
|
|
308
|
+
logger.debug('Resolved config', {
|
|
309
|
+
host: echoOptions.wsHost,
|
|
310
|
+
port: echoOptions.wsPort,
|
|
311
|
+
tls: echoOptions.forceTLS,
|
|
312
|
+
path: echoOptions.wsPath ?? '(default)',
|
|
313
|
+
authEndpoint: echoOptions.authEndpoint
|
|
314
|
+
});
|
|
315
|
+
echoInstance = new config.Echo(echoOptions);
|
|
316
|
+
const liveWindow = getBrowserWindow();
|
|
317
|
+
if (liveWindow) {
|
|
318
|
+
;
|
|
319
|
+
liveWindow.Echo = echoInstance;
|
|
320
|
+
if (config.initializedEvent) {
|
|
321
|
+
liveWindow.dispatchEvent(new CustomEvent(config.initializedEvent));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const connection = resolvePusherConnection(echoInstance);
|
|
325
|
+
if (!connection) {
|
|
326
|
+
logger.error('Unable to access Pusher connection');
|
|
327
|
+
return echoInstance;
|
|
328
|
+
}
|
|
329
|
+
connection.bind('connected', () => {
|
|
330
|
+
if (thisGeneration !== connectionGeneration) {
|
|
331
|
+
logger.debug('Ignoring stale connected event from old connection');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
logger.debug('Connected to Reverb WebSocket server');
|
|
335
|
+
clearConnectionStateReconcile();
|
|
336
|
+
suppressNextDisconnectEvent = false;
|
|
337
|
+
if (suppressDisconnectResetTimeoutId) {
|
|
338
|
+
clearTimeout(suppressDisconnectResetTimeoutId);
|
|
339
|
+
suppressDisconnectResetTimeoutId = null;
|
|
340
|
+
}
|
|
341
|
+
updateConnectionStatus('connected');
|
|
342
|
+
reconnectAttempts = 0;
|
|
343
|
+
if (reconnectTimeoutId) {
|
|
344
|
+
clearTimeout(reconnectTimeoutId);
|
|
345
|
+
reconnectTimeoutId = null;
|
|
346
|
+
}
|
|
347
|
+
startHealthCheck();
|
|
348
|
+
});
|
|
349
|
+
connection.bind('disconnected', () => {
|
|
350
|
+
if (thisGeneration !== connectionGeneration) {
|
|
351
|
+
logger.debug('Ignoring stale disconnected event from old connection');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (suppressNextDisconnectEvent) {
|
|
355
|
+
logger.debug('Skipping reconnect for intentional disconnect');
|
|
356
|
+
suppressNextDisconnectEvent = false;
|
|
357
|
+
if (suppressDisconnectResetTimeoutId) {
|
|
358
|
+
clearTimeout(suppressDisconnectResetTimeoutId);
|
|
359
|
+
suppressDisconnectResetTimeoutId = null;
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
logger.warn('Disconnected from Reverb WebSocket server');
|
|
364
|
+
updateConnectionStatus('disconnected');
|
|
365
|
+
scheduleReconnect();
|
|
366
|
+
});
|
|
367
|
+
connection.bind('error', (error) => {
|
|
368
|
+
if (thisGeneration !== connectionGeneration) {
|
|
369
|
+
logger.debug('Ignoring stale error event from old connection');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const errorCode = getPusherErrorCode(error);
|
|
373
|
+
if (errorCode === TRANSIENT_PUSHER_CLOSE_CODE) {
|
|
374
|
+
if (suppressNextDisconnectEvent) {
|
|
375
|
+
logger.debug('Ignoring PusherError 1006 during intentional disconnect');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
logger.debug('Received PusherError 1006; treating as disconnected');
|
|
379
|
+
updateConnectionStatus('disconnected');
|
|
380
|
+
scheduleReconnect();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
logger.error('Connection error', error);
|
|
384
|
+
updateConnectionStatus('error');
|
|
385
|
+
scheduleReconnect();
|
|
386
|
+
});
|
|
387
|
+
connection.bind('unavailable', () => {
|
|
388
|
+
if (thisGeneration !== connectionGeneration) {
|
|
389
|
+
logger.debug('Ignoring stale unavailable event from old connection');
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (suppressNextDisconnectEvent) {
|
|
393
|
+
logger.debug('Ignoring unavailable event during intentional disconnect');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
logger.debug('Connection unavailable; scheduling reconnect');
|
|
397
|
+
updateConnectionStatus('disconnected');
|
|
398
|
+
scheduleReconnect();
|
|
399
|
+
});
|
|
400
|
+
connection.bind('connecting', () => {
|
|
401
|
+
if (thisGeneration !== connectionGeneration) {
|
|
402
|
+
logger.debug('Ignoring stale connecting event from old connection');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
logger.debug('Connecting to Reverb WebSocket server');
|
|
406
|
+
updateConnectionStatus('connecting');
|
|
407
|
+
scheduleConnectionStateReconcile(connection);
|
|
408
|
+
});
|
|
409
|
+
updateConnectionStatus('connecting');
|
|
410
|
+
if (connection.state === 'connected') {
|
|
411
|
+
updateConnectionStatus('connected');
|
|
412
|
+
}
|
|
413
|
+
scheduleConnectionStateReconcile(connection);
|
|
414
|
+
return echoInstance;
|
|
415
|
+
}
|
|
416
|
+
function getEcho() {
|
|
417
|
+
const liveWindow = getBrowserWindow();
|
|
418
|
+
if (!echoInstance && liveWindow?.Echo) {
|
|
419
|
+
echoInstance = liveWindow.Echo;
|
|
420
|
+
const liveConnection = resolvePusherConnection(echoInstance);
|
|
421
|
+
if (liveConnection?.state === 'connected') {
|
|
422
|
+
updateConnectionStatus('connected');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return echoInstance;
|
|
426
|
+
}
|
|
427
|
+
function disconnectEcho(options = {}) {
|
|
428
|
+
const preventReconnect = options.preventReconnect ?? true;
|
|
429
|
+
if (!preventReconnect) {
|
|
430
|
+
suppressNextDisconnectEvent = false;
|
|
431
|
+
if (suppressDisconnectResetTimeoutId) {
|
|
432
|
+
clearTimeout(suppressDisconnectResetTimeoutId);
|
|
433
|
+
suppressDisconnectResetTimeoutId = null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
clearConnectionStateReconcile();
|
|
437
|
+
stopHealthCheck();
|
|
438
|
+
if (echoInstance) {
|
|
439
|
+
if (preventReconnect) {
|
|
440
|
+
suppressNextDisconnectEvent = true;
|
|
441
|
+
if (suppressDisconnectResetTimeoutId) {
|
|
442
|
+
clearTimeout(suppressDisconnectResetTimeoutId);
|
|
443
|
+
}
|
|
444
|
+
suppressDisconnectResetTimeoutId = setTimeout(() => {
|
|
445
|
+
suppressNextDisconnectEvent = false;
|
|
446
|
+
suppressDisconnectResetTimeoutId = null;
|
|
447
|
+
}, DISCONNECT_SUPPRESSION_RESET_MS);
|
|
448
|
+
if (reconnectTimeoutId) {
|
|
449
|
+
clearTimeout(reconnectTimeoutId);
|
|
450
|
+
reconnectTimeoutId = null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
logger.debug('Disconnecting...');
|
|
454
|
+
echoInstance.disconnect();
|
|
455
|
+
echoInstance = null;
|
|
456
|
+
const liveWindow = getBrowserWindow();
|
|
457
|
+
if (liveWindow) {
|
|
458
|
+
delete liveWindow.Echo;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (preventReconnect && !options.skipStatusUpdate) {
|
|
462
|
+
updateConnectionStatus('disconnected');
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function getConnectionStatus() {
|
|
466
|
+
const liveState = resolvePusherConnection(getBrowserWindow()?.Echo ?? null)?.state;
|
|
467
|
+
if (liveState === 'connected' && connectionStatus !== 'connected') {
|
|
468
|
+
updateConnectionStatus('connected');
|
|
469
|
+
}
|
|
470
|
+
return connectionStatus;
|
|
471
|
+
}
|
|
472
|
+
function onConnectionStatusChange(callback) {
|
|
473
|
+
connectionCallbacks.add(callback);
|
|
474
|
+
return () => {
|
|
475
|
+
connectionCallbacks.delete(callback);
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function onEchoReconnected(callback) {
|
|
479
|
+
reconnectCallbacks.add(callback);
|
|
480
|
+
return () => {
|
|
481
|
+
reconnectCallbacks.delete(callback);
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function subscribeToPresenceStatus(callback) {
|
|
485
|
+
const echo = getEcho();
|
|
486
|
+
if (!echo) {
|
|
487
|
+
logger.warn('Cannot subscribe to presence status, not connected');
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const channelName = `online-users.${resolveTenantScope()}`;
|
|
491
|
+
logger.debug(`Subscribing to ${channelName}`);
|
|
492
|
+
const channel = echo.join(channelName);
|
|
493
|
+
const presenceEventNames = [
|
|
494
|
+
'.PresenceStatusChanged',
|
|
495
|
+
'PresenceStatusChanged',
|
|
496
|
+
'.presence.status.changed',
|
|
497
|
+
'presence.status.changed',
|
|
498
|
+
'.Presence.StatusChanged',
|
|
499
|
+
'Presence.StatusChanged',
|
|
500
|
+
'.Modules\\Presence\\Events\\UserPresenceStatusChanged',
|
|
501
|
+
'Modules\\Presence\\Events\\UserPresenceStatusChanged'
|
|
502
|
+
];
|
|
503
|
+
for (const eventName of presenceEventNames) {
|
|
504
|
+
const validator = (rawEvent) => isRecord(rawEvent);
|
|
505
|
+
guardedListen(channel, eventName, validator, (rawEvent) => {
|
|
506
|
+
const event = normalizePresenceStatusChangedPayload(rawEvent, resolveTenantScope);
|
|
507
|
+
if (!event) {
|
|
508
|
+
logger.warn('Dropping malformed PresenceStatusChanged event', rawEvent);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
logger.debug('Presence status event received', event);
|
|
512
|
+
callback(event);
|
|
513
|
+
}, 'PresenceStatusChanged', logger);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function unsubscribeFromPresenceStatus() {
|
|
517
|
+
const echo = getEcho();
|
|
518
|
+
if (!echo) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const channelName = `online-users.${resolveTenantScope()}`;
|
|
522
|
+
logger.debug(`Unsubscribing from ${channelName}`);
|
|
523
|
+
echo.leave(channelName);
|
|
524
|
+
}
|
|
525
|
+
function subscribeToLiveCircleCount(circleId, callback) {
|
|
526
|
+
const echo = getEcho();
|
|
527
|
+
if (!echo) {
|
|
528
|
+
logger.warn('Cannot subscribe to live circle count, not connected');
|
|
529
|
+
return () => undefined;
|
|
530
|
+
}
|
|
531
|
+
const channelName = `live-circle.${circleId}`;
|
|
532
|
+
logger.debug(`Subscribing to ${channelName}`);
|
|
533
|
+
const channel = echo.private(channelName);
|
|
534
|
+
const handler = guardedListen(channel, '.LiveCircleCountUpdated', isValidLiveCircleCountUpdatedEvent, (data) => {
|
|
535
|
+
logger.debug('Live circle count updated event received', data);
|
|
536
|
+
callback(data);
|
|
537
|
+
}, 'LiveCircleCountUpdated', logger);
|
|
538
|
+
let cleanedUp = false;
|
|
539
|
+
return () => {
|
|
540
|
+
if (cleanedUp) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
cleanedUp = true;
|
|
544
|
+
const stoppableChannel = channel;
|
|
545
|
+
if (typeof stoppableChannel.stopListening !== 'function') {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
stoppableChannel.stopListening('.LiveCircleCountUpdated', handler);
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function subscribeToTutorialComments(tutorialId, callback) {
|
|
552
|
+
const echo = getEcho() ?? initializeEcho();
|
|
553
|
+
if (!echo) {
|
|
554
|
+
logger.warn('Cannot subscribe to tutorial comments, not connected');
|
|
555
|
+
return () => undefined;
|
|
556
|
+
}
|
|
557
|
+
const channelName = `tutorial.${tutorialId}.comments`;
|
|
558
|
+
logger.debug(`Subscribing to ${channelName}`);
|
|
559
|
+
const channel = echo.private(channelName);
|
|
560
|
+
const handler = guardedListen(channel, '.comment.added', isValidTutorialCommentAddedEvent, (data) => {
|
|
561
|
+
logger.debug('Tutorial comment added event received', data);
|
|
562
|
+
callback(data);
|
|
563
|
+
}, 'comment.added', logger);
|
|
564
|
+
let cleanedUp = false;
|
|
565
|
+
return () => {
|
|
566
|
+
if (cleanedUp) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
cleanedUp = true;
|
|
570
|
+
const stoppableChannel = channel;
|
|
571
|
+
if (typeof stoppableChannel.stopListening !== 'function') {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
stoppableChannel.stopListening('.comment.added', handler);
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
function subscribeToTutorialProgress(tutorialId, callback) {
|
|
578
|
+
const echo = getEcho() ?? initializeEcho();
|
|
579
|
+
if (!echo) {
|
|
580
|
+
logger.warn('Cannot subscribe to tutorial progress, not connected');
|
|
581
|
+
return () => undefined;
|
|
582
|
+
}
|
|
583
|
+
const channelName = `tutorial.${tutorialId}.progress`;
|
|
584
|
+
logger.debug(`Subscribing to ${channelName}`);
|
|
585
|
+
const channel = echo.private(channelName);
|
|
586
|
+
channel.listen('pusher:subscription_error', (error) => {
|
|
587
|
+
logger.warn(`Channel subscription failed for ${channelName}`, error);
|
|
588
|
+
});
|
|
589
|
+
const handler = guardedListen(channel, '.progress.updated', isValidTutorialProgressUpdatedEvent, (data) => {
|
|
590
|
+
logger.debug('Tutorial progress updated event received', data);
|
|
591
|
+
callback(data);
|
|
592
|
+
}, 'progress.updated', logger);
|
|
593
|
+
let cleanedUp = false;
|
|
594
|
+
return () => {
|
|
595
|
+
if (cleanedUp) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
cleanedUp = true;
|
|
599
|
+
const stoppableChannel = channel;
|
|
600
|
+
if (typeof stoppableChannel.stopListening !== 'function') {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
stoppableChannel.stopListening('.progress.updated', handler);
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function subscribeToProfileUpdates(userId, callbacks) {
|
|
607
|
+
const echo = getEcho();
|
|
608
|
+
if (!echo) {
|
|
609
|
+
logger.warn('Cannot subscribe to profile updates, not connected');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const channelName = `profiles.${userId}`;
|
|
613
|
+
logger.debug(`Subscribing to ${channelName}`);
|
|
614
|
+
const channel = echo.private(channelName);
|
|
615
|
+
if (callbacks.onStatsUpdated) {
|
|
616
|
+
const onStatsUpdated = callbacks.onStatsUpdated;
|
|
617
|
+
guardedListen(channel, '.Profiles.StatsUpdated', isValidProfileStatsUpdatedEvent, (data) => {
|
|
618
|
+
logger.debug('Profile stats updated event received', data);
|
|
619
|
+
onStatsUpdated(data);
|
|
620
|
+
}, 'Profiles.StatsUpdated', logger);
|
|
621
|
+
}
|
|
622
|
+
if (callbacks.onActivityCreated) {
|
|
623
|
+
const onActivityCreated = callbacks.onActivityCreated;
|
|
624
|
+
guardedListen(channel, '.Profiles.ActivityCreated', isValidProfileActivityCreatedEvent, (data) => {
|
|
625
|
+
logger.debug('Profile activity created event received', data);
|
|
626
|
+
onActivityCreated(data);
|
|
627
|
+
}, 'Profiles.ActivityCreated', logger);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function unsubscribeFromProfileUpdates(userId) {
|
|
631
|
+
const echo = getEcho();
|
|
632
|
+
if (!echo) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const channelName = `profiles.${userId}`;
|
|
636
|
+
logger.debug(`Unsubscribing from ${channelName}`);
|
|
637
|
+
echo.leave(channelName);
|
|
638
|
+
}
|
|
639
|
+
function subscribeToStoryTray(callbacks) {
|
|
640
|
+
const echo = getEcho() ?? initializeEcho();
|
|
641
|
+
if (!echo) {
|
|
642
|
+
logger.warn('Cannot subscribe to story tray channel, not connected');
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
const channelName = `story-tray.${resolveTenantScope()}`;
|
|
646
|
+
logger.debug(`Subscribing to ${channelName}`);
|
|
647
|
+
const channel = echo.private(channelName);
|
|
648
|
+
if (callbacks.onStoryTrayUpdated) {
|
|
649
|
+
const onStoryTrayUpdated = callbacks.onStoryTrayUpdated;
|
|
650
|
+
guardedListen(channel, '.StoryTrayUpdated', isValidStoryTrayUpdatedEvent, (data) => {
|
|
651
|
+
logger.debug('Story tray updated event received', data);
|
|
652
|
+
onStoryTrayUpdated(data);
|
|
653
|
+
}, 'StoryTrayUpdated', logger);
|
|
654
|
+
}
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
function unsubscribeFromStoryTray() {
|
|
658
|
+
const echo = getEcho();
|
|
659
|
+
if (!echo) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const channelName = `story-tray.${resolveTenantScope()}`;
|
|
663
|
+
logger.debug(`Unsubscribing from ${channelName}`);
|
|
664
|
+
echo.leave(channelName);
|
|
665
|
+
}
|
|
666
|
+
function reconnectEcho(options = {}) {
|
|
667
|
+
if (options.resetBackoff) {
|
|
668
|
+
reconnectAttempts = 0;
|
|
669
|
+
}
|
|
670
|
+
if (reconnectTimeoutId) {
|
|
671
|
+
clearTimeout(reconnectTimeoutId);
|
|
672
|
+
reconnectTimeoutId = null;
|
|
673
|
+
}
|
|
674
|
+
updateConnectionStatus('connecting');
|
|
675
|
+
if (echoInstance) {
|
|
676
|
+
logger.debug('Disconnecting before reconnect');
|
|
677
|
+
disconnectEcho({ preventReconnect: true, skipStatusUpdate: true });
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
echoInstance = initializeEcho();
|
|
681
|
+
if (!echoInstance) {
|
|
682
|
+
logger.error('Reconnect failed: initializeEcho returned null');
|
|
683
|
+
updateConnectionStatus('error');
|
|
684
|
+
scheduleReconnect();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
logger.error('Reconnect failed', error);
|
|
689
|
+
updateConnectionStatus('error');
|
|
690
|
+
scheduleReconnect();
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
initializeEcho,
|
|
695
|
+
getEcho,
|
|
696
|
+
disconnectEcho,
|
|
697
|
+
reconnectEcho,
|
|
698
|
+
getConnectionStatus,
|
|
699
|
+
onConnectionStatusChange,
|
|
700
|
+
onEchoReconnected,
|
|
701
|
+
subscribeToPresenceStatus,
|
|
702
|
+
unsubscribeFromPresenceStatus,
|
|
703
|
+
subscribeToLiveCircleCount,
|
|
704
|
+
subscribeToTutorialComments,
|
|
705
|
+
subscribeToTutorialProgress,
|
|
706
|
+
subscribeToProfileUpdates,
|
|
707
|
+
unsubscribeFromProfileUpdates,
|
|
708
|
+
subscribeToStoryTray,
|
|
709
|
+
unsubscribeFromStoryTray
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
//# sourceMappingURL=echo.js.map
|