@product7/product7-js 0.6.4 → 0.6.7

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.
@@ -8216,11 +8216,28 @@
8216
8216
 
8217
8217
  this.ws = null;
8218
8218
  this.reconnectAttempts = 0;
8219
- this.maxReconnectAttempts = 5;
8220
- this.reconnectDelay = 1000;
8219
+ // Reconnect indefinitely with capped exponential backoff. The
8220
+ // previous 5-attempt hard cap meant ~31s of churn on a flaky network
8221
+ // permanently killed the live-chat connection until page refresh —
8222
+ // the dominant "messages don't arrive" symptom in production.
8223
+ this.reconnectBaseDelay = 1000;
8224
+ this.reconnectMaxDelay = 30_000;
8221
8225
  this.pingInterval = null;
8222
8226
  this.isConnected = false;
8223
8227
 
8228
+ // Heartbeat watchdog. The browser's onclose/onerror only fires when
8229
+ // it notices the TCP socket is dead — on stalled-but-not-closed
8230
+ // connections (NAT timeouts, sleeping mobile, some intermediaries)
8231
+ // that can take many minutes. We track the wall-clock time of the
8232
+ // last received frame and force a reconnect if nothing arrives
8233
+ // within the timeout. Server's protocol-level pings come every 54s
8234
+ // (pkg/websocket/client.go pingPeriod) and our app-level pings/pongs
8235
+ // every 30s, so 90s catches a dead pipe with one cycle of headroom.
8236
+ this.lastFrameAt = 0;
8237
+ this.heartbeatTimeoutMs = 90_000;
8238
+ this.heartbeatCheckIntervalMs = 15_000;
8239
+ this.heartbeatInterval = null;
8240
+
8224
8241
  // Event listeners
8225
8242
  this._listeners = new Map();
8226
8243
 
@@ -8244,6 +8261,8 @@
8244
8261
  return;
8245
8262
  }
8246
8263
 
8264
+ this._intentionallyClosed = false;
8265
+
8247
8266
  // Mock mode - simulate connection
8248
8267
  if (this.mock) {
8249
8268
  this.isConnected = true;
@@ -8275,13 +8294,15 @@
8275
8294
  */
8276
8295
  disconnect() {
8277
8296
  this.isConnected = false;
8278
- this.reconnectAttempts = this.maxReconnectAttempts; // Prevent reconnection
8297
+ this._intentionallyClosed = true; // Prevent reconnection
8279
8298
 
8280
8299
  if (this.pingInterval) {
8281
8300
  clearInterval(this.pingInterval);
8282
8301
  this.pingInterval = null;
8283
8302
  }
8284
8303
 
8304
+ this._stopHeartbeat();
8305
+
8285
8306
  if (this.ws) {
8286
8307
  this.ws.close();
8287
8308
  this.ws = null;
@@ -8293,6 +8314,35 @@
8293
8314
  }
8294
8315
  }
8295
8316
 
8317
+ _startHeartbeat() {
8318
+ this._stopHeartbeat();
8319
+ this.lastFrameAt = Date.now();
8320
+ this.heartbeatInterval = setInterval(() => {
8321
+ if (Date.now() - this.lastFrameAt > this.heartbeatTimeoutMs) {
8322
+ console.warn(
8323
+ `[WebSocket] No frames in ${this.heartbeatTimeoutMs}ms, forcing reconnect`
8324
+ );
8325
+ // Closing the socket fires onclose, which schedules a
8326
+ // reconnect through the normal path.
8327
+ this._stopHeartbeat();
8328
+ if (this.ws) {
8329
+ try {
8330
+ this.ws.close();
8331
+ } catch (_) {
8332
+ // ignore
8333
+ }
8334
+ }
8335
+ }
8336
+ }, this.heartbeatCheckIntervalMs);
8337
+ }
8338
+
8339
+ _stopHeartbeat() {
8340
+ if (this.heartbeatInterval) {
8341
+ clearInterval(this.heartbeatInterval);
8342
+ this.heartbeatInterval = null;
8343
+ }
8344
+ }
8345
+
8296
8346
  /**
8297
8347
  * Subscribe to events
8298
8348
  * @param {string} event - Event name
@@ -8342,6 +8392,8 @@
8342
8392
  console.log('[WebSocket] Connected');
8343
8393
  this.isConnected = true;
8344
8394
  this.reconnectAttempts = 0;
8395
+ this._intentionallyClosed = false;
8396
+ this._startHeartbeat();
8345
8397
  this._emit('connected', {});
8346
8398
 
8347
8399
  // Start ping interval to keep connection alive
@@ -8351,6 +8403,9 @@
8351
8403
  }
8352
8404
 
8353
8405
  _onMessage(event) {
8406
+ // Any inbound frame counts as a sign of life for the watchdog —
8407
+ // including pongs, message:new, typing, etc.
8408
+ this.lastFrameAt = Date.now();
8354
8409
  try {
8355
8410
  const data = JSON.parse(event.data);
8356
8411
  const { type, payload } = data;
@@ -8395,8 +8450,13 @@
8395
8450
  this.pingInterval = null;
8396
8451
  }
8397
8452
 
8453
+ this._stopHeartbeat();
8454
+
8398
8455
  this._emit('disconnected', { code: event.code, reason: event.reason });
8399
- this._scheduleReconnect();
8456
+ // Skip reconnect if disconnect() was called intentionally.
8457
+ if (!this._intentionallyClosed) {
8458
+ this._scheduleReconnect();
8459
+ }
8400
8460
  }
8401
8461
 
8402
8462
  _onError(error) {
@@ -8405,14 +8465,13 @@
8405
8465
  }
8406
8466
 
8407
8467
  _scheduleReconnect() {
8408
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
8409
- console.log('[WebSocket] Max reconnect attempts reached');
8410
- this._emit('reconnect_failed', {});
8411
- return;
8412
- }
8468
+ if (this._intentionallyClosed) return;
8413
8469
 
8414
8470
  this.reconnectAttempts++;
8415
- const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
8471
+ const delay = Math.min(
8472
+ this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1),
8473
+ this.reconnectMaxDelay
8474
+ );
8416
8475
  console.log(
8417
8476
  `[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`
8418
8477
  );
@@ -11076,12 +11135,11 @@
11076
11135
  <div class="liveChat-home-logo">
11077
11136
  ${this.options.logoUrl ? `<img src="${this.options.logoUrl}" alt="${this.state.teamName}" />` : ''}
11078
11137
  </div>
11079
- <div class="liveChat-home-avatars">${avatarsHtml}</div>
11138
+ <div class="liveChat-home-avatars">${avatarsHtml || availabilityHtml}</div>
11080
11139
  </div>
11081
11140
  <div class="liveChat-home-welcome">
11082
11141
  <span class="liveChat-home-greeting">${this.state.greetingMessage}</span>
11083
11142
  <span class="liveChat-home-question">${this.state.welcomeMessage}</span>
11084
- ${availabilityHtml}
11085
11143
  </div>
11086
11144
  </div>
11087
11145
 
@@ -11144,7 +11202,7 @@
11144
11202
  return `
11145
11203
  <div class="liveChat-home-availability">
11146
11204
  <span class="liveChat-availability-dot liveChat-availability-away"></span>
11147
- <span class="liveChat-availability-text">${this.state.responseTime}</span>
11205
+ <span class="liveChat-availability-text">We're currently away</span>
11148
11206
  </div>
11149
11207
  `;
11150
11208
  }
@@ -11918,7 +11976,38 @@
11918
11976
  });
11919
11977
 
11920
11978
  if (response.status && response.data) {
11921
- console.log('[LiveChatWidget] Message sent:', response.data.id);
11979
+ // Reconcile the optimistic 'Sending…' message with the server's
11980
+ // confirmation as soon as the POST returns. Don't depend on the WS
11981
+ // echo alone — when the WebSocket frame is dropped (brief
11982
+ // disconnect, slow network, server send-buffer full), the
11983
+ // optimistic message stays stuck on 'Sending…' forever because
11984
+ // upsertMessage's match window is finite.
11985
+ const serverData = response.data;
11986
+ let attachments = [];
11987
+ if (serverData.attachments) {
11988
+ try {
11989
+ attachments =
11990
+ typeof serverData.attachments === 'string'
11991
+ ? JSON.parse(serverData.attachments)
11992
+ : serverData.attachments;
11993
+ } catch (e) {
11994
+ attachments = [];
11995
+ }
11996
+ }
11997
+ this.LiveChatState.upsertMessage(
11998
+ conversationId,
11999
+ {
12000
+ id: serverData.id,
12001
+ content: serverData.content,
12002
+ isOwn: true,
12003
+ timestamp: serverData.created_at,
12004
+ attachments:
12005
+ Array.isArray(attachments) && attachments.length > 0
12006
+ ? attachments
12007
+ : undefined,
12008
+ },
12009
+ { reconcileOwnOptimistic: true, optimisticMatchWindowMs: 60000 }
12010
+ );
11922
12011
  }
11923
12012
 
11924
12013
  if (this.apiService?.mock) {
@@ -12051,13 +12140,34 @@
12051
12140
  this._wsUnsubscribers.push(
12052
12141
  this.wsService.on('conversation_closed', this._handleConversationClosed)
12053
12142
  );
12143
+ // Track first vs reconnect locally so we only backfill on reconnects.
12144
+ // First connect is right after _initWebSocket() and the surrounding
12145
+ // flow has already loaded fresh data — re-fetching would be wasted.
12146
+ let wsHasConnectedBefore = false;
12054
12147
  this._wsUnsubscribers.push(
12055
12148
  this.wsService.on('connected', () => {
12056
12149
  console.log('[LiveChatWidget] WebSocket connected');
12150
+ const isReconnect = wsHasConnectedBefore;
12151
+ wsHasConnectedBefore = true;
12057
12152
  if (this.LiveChatState.activeConversationId) {
12058
12153
  this.wsService.send('conversation:subscribe', {
12059
12154
  conversation_id: this.LiveChatState.activeConversationId,
12060
12155
  });
12156
+ // On reconnect, refetch the active conversation's messages.
12157
+ // The server doesn't replay events that fired during the WS
12158
+ // disconnect, so anything broadcast in the gap is otherwise
12159
+ // permanently lost from the customer's view until they
12160
+ // refresh the page.
12161
+ if (isReconnect) {
12162
+ this.fetchMessages(this.LiveChatState.activeConversationId).catch(
12163
+ (err) => {
12164
+ console.error(
12165
+ '[LiveChatWidget] Failed to backfill messages on reconnect:',
12166
+ err
12167
+ );
12168
+ }
12169
+ );
12170
+ }
12061
12171
  }
12062
12172
  })
12063
12173
  );