posthog-js
Version:
Posthog-js allows you to automatically capture usage and send events to PostHog.
1,680 lines • 85.9 kB
JavaScript
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __read = (this && this.__read) || function (o, n) {
var m = typeof Symbol === "function" && o[Symbol.iterator];
if (!m) return o;
var i = m.call(o), r, ar = [], e;
try {
while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
}
catch (error) { e = { error: error }; }
finally {
try {
if (r && !r.done && (m = i["return"])) m.call(i);
}
finally { if (e) throw e.error; }
}
return ar;
};
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || Array.prototype.slice.call(from));
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ConversationsManager = void 0;
exports.initConversations = initConversations;
var jsx_runtime_1 = require("preact/jsx-runtime");
// eslint-disable-next-line @typescript-eslint/no-unused-vars
var preact_1 = require("preact");
var core_1 = require("@posthog/core");
var constants_1 = require("../../../constants");
var persistence_1 = require("./persistence");
var ConversationsWidget_1 = require("./components/ConversationsWidget");
var logger_1 = require("@posthog/browser-common/utils/logger");
var globals_1 = require("@posthog/browser-common/utils/globals");
var request_utils_1 = require("@posthog/browser-common/utils/request-utils");
var url_utils_1 = require("./url-utils");
var general_utils_1 = require("@posthog/browser-common/utils/general-utils");
var errors_1 = require("./errors");
var logger = (0, logger_1.createLogger)('[ConversationsManager]');
var createConversationsRequestError = function (response, operation, fallbackMessage, rateLimitIsWarning, logFailure) {
var _a, _b, _c;
if (rateLimitIsWarning === void 0) { rateLimitIsWarning = false; }
if (logFailure === void 0) { logFailure = true; }
if (response.statusCode === 0) {
// Fetch failures are already logged by `_send_request`. XHR reports status 0
// without an error, so keep one warning for that otherwise-unlogged path.
var error = (0, errors_1.createConversationsError)('network', 'Unable to reach the server. Please check your connection and try again.');
if (response.error) {
var errorWithCause = error;
errorWithCause.cause = response.error;
}
else if (logFailure) {
logger.warn("Network error ".concat(operation));
}
return error;
}
if (response.statusCode === 429) {
if (logFailure) {
if (rateLimitIsWarning) {
logger.warn("Rate limited ".concat(operation));
}
else {
logger.error(fallbackMessage, { status: response.statusCode });
}
}
return (0, errors_1.createConversationsError)('rate_limit', 'Too many requests. Please wait before trying again.');
}
var message = ((_a = response.json) === null || _a === void 0 ? void 0 : _a.error) || ((_b = response.json) === null || _b === void 0 ? void 0 : _b.detail) || ((_c = response.json) === null || _c === void 0 ? void 0 : _c.message) || fallbackMessage;
if (logFailure) {
logger.error(fallbackMessage, { status: response.statusCode });
}
return (0, errors_1.createConversationsError)('http', message);
};
var createInvalidConversationsResponseError = function (operation, logFailure) {
if (logFailure === void 0) { logFailure = true; }
var message = 'Invalid response from server';
if (logFailure) {
logger.error(message, { operation: operation });
}
return (0, errors_1.createConversationsError)('invalid_response', message);
};
var WIDGET_CONTAINER_ID = 'ph-conversations-widget-container';
// Polling cadence. The widget polls faster while open (so replies feel live) and
// much slower while closed (the badge can lag a few seconds). Idle widgets with no
// conversation don't poll at all — see _hasSomethingToPoll.
var POLL_INTERVAL_OPEN_MS = 5000; // 5 seconds
var POLL_INTERVAL_CLOSED_MS = 15000; // 15 seconds
// On HTTP 429 we back off exponentially instead of hammering the shared team budget,
// which is what starves visitor sends (the send and poll budgets are separate server-side,
// but polling still competes with everyone else's polls).
var POLL_RATE_LIMIT_BASE_MS = 5000; // 5 seconds
var POLL_RATE_LIMIT_MAX_MS = 60000; // 1 minute
// How often to check that the widget container is still attached to the DOM.
// SPA frameworks that replace document.body on navigation (e.g. Turbo Drive)
// detach our container; this watcher re-attaches it so the widget survives.
var REATTACH_CHECK_INTERVAL_MS = 1000; // 1 second
var RESTORE_EXCHANGE_ENDPOINT = '/api/conversations/v1/widget/restore';
var RESTORE_REQUEST_ENDPOINT = '/api/conversations/v1/widget/restore/request';
// Polling runs every 5s while the widget is visible, so repeated status-0
// failures can become an endless blocked-request loop. Match the browser event
// retry budget and pause polling after this many consecutive online failures.
var MAX_CONSECUTIVE_POLLING_STATUS_ZERO_FAILURES = 3;
// Singleton guard: only one ConversationsManager per page.
// The toolbar's internal PostHog instance is excluded from creating a manager
// (see PostHogConversations.loadIfEnabled), so this always belongs to the main instance.
var _activeManager = null;
var ConversationsManager = /** @class */ (function () {
function ConversationsManager(config, _posthog) {
var _this = this;
this._posthog = _posthog;
this._widgetRef = null;
this._containerElement = null;
this._currentTicketId = null;
this._pollTimeoutId = null;
// Guards against a second _startPolling() while the first poll is still in flight
// (the timeout id isn't set until the poll resolves and schedules the next tick).
this._pollLoopRunning = false;
// Bumped by every start/stop. An in-flight poll chain captures the generation it
// started with and abandons itself if a stop/restart happened while it awaited,
// so open/close (which stops then starts) can never leave two loops running.
this._pollGeneration = 0;
this._consecutivePollingRateLimitFailures = 0;
this._reattachIntervalId = null;
this._lastMessageTimestamp = null;
this._isPollingMessages = false;
this._isPollingTickets = false;
this._unsubscribeIdentifyListener = null;
this._unreadCount = 0;
this._widgetState = 'closed';
this._isWidgetRendered = false;
this._hasProcessedRestoreToken = false;
this._initializeWidgetPromise = null;
// View state management for ticket list vs message view
this._currentView = 'messages';
this._tickets = [];
this._showTicketList = false;
this._consecutivePollingStatusZeroFailures = 0;
this._onOnline = function () {
_this._consecutivePollingStatusZeroFailures = 0;
_this._consecutivePollingRateLimitFailures = 0;
// Clearing the streak alone leaves any pending long backoff timeout in place,
// so messages/unread would stay stale until it fires (up to a minute).
// Restarting polls immediately and reschedules at the normal cadence.
if (_this._pollLoopRunning) {
_this._stopPolling();
_this._startPolling();
}
};
/**
* Handle user identification from the widget form
*/
this._handleIdentify = function (traits) {
// Save traits to localStorage
_this._persistence.saveUserTraits(traits);
// Track identification
_this._posthog.capture('$conversations_user_identified', {
hasName: !!traits.name,
hasEmail: !!traits.email,
});
};
this._handleRequestRestoreLink = function (email) { return __awaiter(_this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, this.requestRestoreLink(email)];
case 1:
response = _a.sent();
this._posthog.capture('$conversations_restore_link_requested', {
hasEmail: !!email,
});
return [2 /*return*/, response];
}
});
}); };
/**
* Handle sending a message from the widget
*/
this._handleSendMessage = function (message) { return __awaiter(_this, void 0, void 0, function () {
var userTraits;
var _this = this;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
userTraits = ((_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.getUserTraits()) || undefined;
// Call the public API method (which handles tracking and state updates).
// Any rejection is propagated to the widget, which renders the error state and logs it -
// logging it here as well would surface a benign network failure as a captured exception.
return [4 /*yield*/, this.sendMessage(message, userTraits)
// Poll for response immediately (sendMessage already resumed the loop)
];
case 1:
// Call the public API method (which handles tracking and state updates).
// Any rejection is propagated to the widget, which renders the error state and logs it -
// logging it here as well would surface a benign network failure as a captured exception.
_b.sent();
// Poll for response immediately (sendMessage already resumed the loop)
setTimeout(function () { return _this._pollMessages(); }, 1000);
return [2 /*return*/];
}
});
}); };
/**
* Handle widget state changes
*/
this._handleStateChange = function (state) {
_this._widgetState = state;
logger.info('Widget state changed', { state: state, view: _this._currentView });
_this._posthog.capture('$conversations_widget_state_changed', {
state: state,
view: _this._currentView,
ticketId: _this._currentTicketId,
});
_this._persistence.saveWidgetState(state);
// Restart the loop so the new open/closed cadence takes effect immediately
// (and opening triggers a fresh poll rather than waiting out a closed interval).
if (_this._isWidgetRendered) {
_this._stopPolling();
_this._startPolling();
}
// Mark messages as read when widget opens (only if in message view with a ticket)
if (state === 'open') {
if (_this._currentView === 'messages' && _this._unreadCount > 0 && _this._currentTicketId) {
_this._markMessagesAsRead();
}
}
};
/**
* Poll for new messages
*/
this._pollMessages = function () { return __awaiter(_this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (this._isPollingMessages ||
!this._currentTicketId ||
(0, request_utils_1.isStatusZeroFailureCircuitBreakerTripped)(this._consecutivePollingStatusZeroFailures, MAX_CONSECUTIVE_POLLING_STATUS_ZERO_FAILURES)) {
return [2 /*return*/];
}
this._isPollingMessages = true;
_a.label = 1;
case 1:
_a.trys.push([1, , 3, 4]);
return [4 /*yield*/, this._loadMessages()];
case 2:
_a.sent();
return [3 /*break*/, 4];
case 3:
this._isPollingMessages = false;
return [7 /*endfinally*/];
case 4: return [2 /*return*/];
}
});
}); };
/**
* Poll for tickets list
*/
this._pollTickets = function () { return __awaiter(_this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (this._isPollingTickets ||
(0, request_utils_1.isStatusZeroFailureCircuitBreakerTripped)(this._consecutivePollingStatusZeroFailures, MAX_CONSECUTIVE_POLLING_STATUS_ZERO_FAILURES)) {
return [2 /*return*/];
}
this._isPollingTickets = true;
_a.label = 1;
case 1:
_a.trys.push([1, , 3, 4]);
return [4 /*yield*/, this._loadTickets()];
case 2:
_a.sent();
return [3 /*break*/, 4];
case 3:
this._isPollingTickets = false;
return [7 /*endfinally*/];
case 4: return [2 /*return*/];
}
});
}); };
/**
* Main poll function that polls based on current view
*/
this._poll = function () { return __awaiter(_this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (this._currentView === 'restore_request') {
return [2 /*return*/];
}
if (!(this._currentView === 'messages')) return [3 /*break*/, 2];
return [4 /*yield*/, this._pollMessages()];
case 1:
_a.sent();
return [3 /*break*/, 4];
case 2: return [4 /*yield*/, this._pollTickets()];
case 3:
_a.sent();
_a.label = 4;
case 4: return [2 /*return*/];
}
});
}); };
/**
* Handle view changes from the widget
*/
this._handleViewChange = function (view) {
logger.info('View changed', { from: _this._currentView, to: view });
_this._currentView = view;
};
/**
* Handle ticket selection from the list
*/
this._handleSelectTicket = function (ticketId) { return __awaiter(_this, void 0, void 0, function () {
var _a, _b, _c;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
// Switch to this ticket
this._switchToTicketIfNeeded(ticketId);
// Clear messages and reset timestamp
this._lastMessageTimestamp = null;
(_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.clearMessages();
// Switch view to messages
this._currentView = 'messages';
(_b = this._widgetRef) === null || _b === void 0 ? void 0 : _b.setView('messages');
// Push resolved state for this ticket so MessagesView locks the input if needed
(_c = this._widgetRef) === null || _c === void 0 ? void 0 : _c.setCurrentTicketResolved(this._isCurrentTicketResolved());
// Selecting a ticket gives the loop something to poll again
this._startPolling();
// Load messages for the selected ticket
return [4 /*yield*/, this._loadMessages()
// Mark as read if widget is open
];
case 1:
// Load messages for the selected ticket
_d.sent();
// Mark as read if widget is open
if (this._isWidgetOpen() && this._unreadCount > 0) {
this._markMessagesAsRead();
}
return [2 /*return*/];
}
});
}); };
/**
* Handle new conversation request
*/
this._handleNewConversation = function () {
var _a, _b, _c;
logger.info('New conversation requested');
// Clear current ticket
_this._currentTicketId = null;
_this._persistence.clearTicketId();
// Reset timestamp
_this._lastMessageTimestamp = null;
// Switch view to messages
_this._currentView = 'messages';
(_a = _this._widgetRef) === null || _a === void 0 ? void 0 : _a.setView('messages');
// Fresh ticket is never resolved — unlock the input
(_b = _this._widgetRef) === null || _b === void 0 ? void 0 : _b.setCurrentTicketResolved(false);
// Clear messages and add greeting
(_c = _this._widgetRef) === null || _c === void 0 ? void 0 : _c.clearMessages(true);
};
/**
* Handle back to tickets request
*/
this._handleBackToTickets = function () { return __awaiter(_this, void 0, void 0, function () {
var _a, _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
logger.info('Back to tickets requested');
// Switch view to tickets
this._currentView = 'tickets';
(_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.setView('tickets');
// Load tickets
(_b = this._widgetRef) === null || _b === void 0 ? void 0 : _b.setTicketsLoading(true);
return [4 /*yield*/, this._loadTickets()
// Resume the loop if it had stopped (tickets view polls the list)
];
case 1:
_c.sent();
// Resume the loop if it had stopped (tickets view polls the list)
this._startPolling();
// Track back to tickets
this._posthog.capture('$conversations_back_to_tickets');
return [2 /*return*/];
}
});
}); };
this._pollThenSchedule = function (generation) { return __awaiter(_this, void 0, void 0, function () {
var error_1;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, this._poll()];
case 1:
_a.sent();
return [3 /*break*/, 3];
case 2:
error_1 = _a.sent();
// _poll is defensively coded not to throw, but never let an unexpected
// error kill the loop (which would leave _pollLoopRunning stuck true).
logger.error('Polling iteration failed', error_1);
return [3 /*break*/, 3];
case 3:
// A stop/restart bumped the generation while the request was in flight;
// abandon this chain so we don't end up running two loops.
if (generation !== this._pollGeneration) {
return [2 /*return*/];
}
this._scheduleNextPoll(generation);
return [2 /*return*/];
}
});
}); };
this._config = config;
this._persistence = new persistence_1.ConversationsPersistence(_posthog);
this._widgetSessionId = this._persistence.getOrCreateWidgetSessionId();
// Determine if widget should be shown based on config and domain
this._isWidgetEnabled = config.widgetEnabled === true;
this._isDomainAllowed = (0, url_utils_1.isCurrentDomainAllowed)(config.domains);
if (globals_1.window) {
(0, general_utils_1.addEventListener)(globals_1.window, 'online', this._onOnline);
}
this._initialize();
}
ConversationsManager.prototype._currentUrl = function () {
var _a;
var href = (_a = globals_1.window === null || globals_1.window === void 0 ? void 0 : globals_1.window.location) === null || _a === void 0 ? void 0 : _a.href;
return this._posthog.config.disable_capture_url_hashes ? (0, core_1.stripUrlHash)(href) : href;
};
/**
* Send a message programmatically via the API
* Creates a new ticket if none exists or if newTicket is true
*
* @param message - The message text to send
* @param userTraits - Optional user identification data (name, email)
* @param newTicket - If true, forces creation of a new ticket (ignores current ticket)
* @returns Promise with the response including ticket_id and message_id
*/
ConversationsManager.prototype.sendMessage = function (message, userTraits, newTicket) {
return __awaiter(this, void 0, void 0, function () {
var ticketId, isNewTicket, token;
var _this = this;
return __generator(this, function (_a) {
ticketId = newTicket ? null : this._currentTicketId;
isNewTicket = !ticketId;
token = this._config.token;
// eslint-disable-next-line compat/compat
return [2 /*return*/, new Promise(function (resolve, reject) {
var personTraits = _this._getPersonTraits();
var name = (userTraits === null || userTraits === void 0 ? void 0 : userTraits.name) || personTraits.name || null;
var email = (userTraits === null || userTraits === void 0 ? void 0 : userTraits.email) || personTraits.email || null;
var identity = _this._identityFields();
var payload = {
message: message.trim(),
traits: {
name: name,
email: email,
},
ticket_id: ticketId,
};
if (identity) {
payload.identity_distinct_id = identity.identity_distinct_id;
payload.identity_hash = identity.identity_hash;
payload.distinct_id = identity.identity_distinct_id;
}
else {
payload.widget_session_id = _this._widgetSessionId;
payload.distinct_id = _this._posthog.get_distinct_id();
}
try {
// Capture session ID - sent with every message
var capturedSessionId = _this._posthog.get_session_id();
if (capturedSessionId) {
payload.session_id = capturedSessionId;
}
// Capture session replay URL with timestamp - sent with every message
var replayUrl = _this._posthog.get_session_replay_url({
withTimestamp: true,
timestampLookBack: 30,
});
// Capture current URL - only for new tickets to record where user started
var currentUrl = isNewTicket ? _this._currentUrl() : undefined;
if (replayUrl || currentUrl) {
payload.session_context = {
session_replay_url: replayUrl || undefined,
current_url: currentUrl || undefined,
};
}
}
catch (error) {
// Log error but don't fail message sending
logger.warn('Failed to capture session context', error);
}
_this._posthog._send_request({
url: _this._posthog.requestRouter.endpointFor('api', '/api/conversations/v1/widget/message'),
method: 'POST',
data: payload,
headers: {
'X-Conversations-Token': token,
},
callback: function (response) {
if (response.statusCode !== 200 && response.statusCode !== 201) {
reject(createConversationsRequestError(response, 'sending message', 'Failed to send message', true));
return;
}
if (!response.json) {
reject(createInvalidConversationsResponseError('sending message'));
return;
}
var data = response.json;
// Update current ticket ID if this was a new ticket
// This happens when: 1) No ticket existed, or 2) User forced new ticket creation
if (isNewTicket && data.ticket_id) {
_this._currentTicketId = data.ticket_id;
_this._persistence.saveTicketId(data.ticket_id);
logger.info('New ticket created', {
ticketId: data.ticket_id,
forced: newTicket === true,
});
}
// Track message sent
_this._posthog.capture('$conversations_message_sent', {
ticketId: data.ticket_id,
isNewTicket: isNewTicket,
messageLength: message.length,
});
// Update last message timestamp
_this._lastMessageTimestamp = data.created_at;
// A ticket now exists, so resume polling if the loop had stopped
// because there was nothing to poll. Only when a widget is rendered —
// programmatic API-only usage never polls.
if (_this._isWidgetRendered) {
_this._startPolling();
}
resolve(data);
},
});
})];
});
});
};
/**
* Switch to a different ticket if an explicit ticketId is provided
* This ensures subsequent operations (sendMessage, etc.) use the correct ticket
*/
ConversationsManager.prototype._switchToTicketIfNeeded = function (ticketId) {
if (ticketId && ticketId !== this._currentTicketId) {
this._currentTicketId = ticketId;
this._persistence.saveTicketId(ticketId);
// Reset last message timestamp when switching tickets
this._lastMessageTimestamp = null;
}
};
/** Fetch messages via the API */
ConversationsManager.prototype.getMessages = function (ticketId, after) {
return __awaiter(this, void 0, void 0, function () {
var targetTicketId, token;
var _this = this;
return __generator(this, function (_a) {
targetTicketId = ticketId || this._currentTicketId;
if (!targetTicketId) {
throw new Error('No ticket ID provided and no active conversation');
}
// Switch to this ticket if explicitly provided
this._switchToTicketIfNeeded(ticketId);
token = this._config.token;
// eslint-disable-next-line compat/compat
return [2 /*return*/, new Promise(function (resolve, reject) {
var identity = _this._identityFields();
var queryParams = {
limit: '50',
};
if (identity) {
queryParams.identity_distinct_id = identity.identity_distinct_id;
queryParams.identity_hash = identity.identity_hash;
}
else {
queryParams.widget_session_id = _this._widgetSessionId;
}
if (after) {
queryParams.after = after;
}
_this._posthog._send_request({
url: _this._posthog.requestRouter.endpointFor('api', "/api/conversations/v1/widget/messages/".concat(targetTicketId, "?").concat((0, request_utils_1.formDataToQuery)(queryParams))),
method: 'GET',
timestampMode: 'query',
headers: {
'X-Conversations-Token': token,
},
callback: function (response) {
_this._trackPollingEndpointReachability(response.statusCode);
_this._trackPollingRateLimit(response.statusCode);
if (response.statusCode !== 200) {
reject(createConversationsRequestError(response, 'fetching messages', 'Failed to fetch messages'));
return;
}
if (!response.json) {
reject(createInvalidConversationsResponseError('fetching messages'));
return;
}
var data = response.json;
resolve(data);
},
});
})];
});
});
};
/** Mark messages as read via the API */
ConversationsManager.prototype.markAsRead = function (ticketId) {
return __awaiter(this, void 0, void 0, function () {
var targetTicketId, token;
var _this = this;
return __generator(this, function (_a) {
targetTicketId = ticketId || this._currentTicketId;
if (!targetTicketId) {
throw new Error('No ticket ID provided and no active conversation');
}
// Switch to this ticket if explicitly provided
this._switchToTicketIfNeeded(ticketId);
token = this._config.token;
logger.info('Marking messages as read', { ticketId: targetTicketId });
// eslint-disable-next-line compat/compat
return [2 /*return*/, new Promise(function (resolve, reject) {
var identity = _this._identityFields();
var data = identity
? { identity_distinct_id: identity.identity_distinct_id, identity_hash: identity.identity_hash }
: { widget_session_id: _this._widgetSessionId };
_this._posthog._send_request({
url: _this._posthog.requestRouter.endpointFor('api', "/api/conversations/v1/widget/messages/".concat(targetTicketId, "/read")),
method: 'POST',
data: data,
headers: {
'X-Conversations-Token': token,
},
callback: function (response) {
if (response.statusCode !== 200) {
reject(createConversationsRequestError(response, 'marking messages as read', 'Failed to mark messages as read'));
return;
}
if (!response.json) {
reject(createInvalidConversationsResponseError('marking messages as read'));
return;
}
var responseData = response.json;
resolve(responseData);
},
});
})];
});
});
};
/**
* Initialize the conversations manager.
* Always initializes persistence and event listeners for API usage.
* Only renders the widget if widgetEnabled is true AND domain is allowed.
*/
ConversationsManager.prototype._initialize = function () {
var _this = this;
if (!globals_1.document || !globals_1.window) {
logger.info('Conversations not available: Document or window not available');
return;
}
// In identity mode, restore is unnecessary and the persisted anonymous
// ticket belongs to a different principal -- clear it before completing.
if (this._identityFields()) {
this._persistence.clearTicketId();
this._completeInitialization();
return;
}
var restoreToken = (0, url_utils_1.getRestoreTokenFromUrl)();
if (restoreToken && !this._hasProcessedRestoreToken) {
this._hasProcessedRestoreToken = true;
// Clear the token from the URL immediately, then again after a tick and
// after the restore completes. SPA routers (Next.js, React Router, etc.)
// maintain their own URL state and can overwrite a single replaceState call.
(0, url_utils_1.clearRestoreTokenFromUrl)();
setTimeout(url_utils_1.clearRestoreTokenFromUrl, 0);
this._restoreFromTokenWithRetry(restoreToken)
.catch(function (error) {
if (!(0, errors_1.isConversationsError)(error)) {
logger.warn('Failed to restore conversations from URL token', error);
}
})
.finally(function () {
(0, url_utils_1.clearRestoreTokenFromUrl)();
_this._completeInitialization();
});
return;
}
this._completeInitialization();
};
ConversationsManager.prototype._completeInitialization = function () {
this._hasProcessedRestoreToken = true;
// Load any existing ticket ID from localStorage
this._currentTicketId = this._persistence.loadTicketId();
logger.info('Loaded ticket ID from storage', { ticketId: this._currentTicketId });
// Track conversations API loaded (separate from widget loaded)
this._posthog.capture('$conversations_loaded', {
hasExistingTicket: !!this._currentTicketId,
widgetEnabled: this._isWidgetEnabled,
domainAllowed: this._isDomainAllowed,
});
// Only render widget if both widgetEnabled and domain is allowed
if (this._isWidgetEnabled && this._isDomainAllowed) {
this._initializeWidget();
}
else {
logger.info('Widget not rendered', {
widgetEnabled: this._isWidgetEnabled,
domainAllowed: this._isDomainAllowed,
});
}
// Listen for identify events to hide identification form when user identifies
this._setupIdentifyListener();
};
ConversationsManager.prototype._restoreFromTokenWithRetry = function (restoreToken) {
return __awaiter(this, void 0, void 0, function () {
var error_2, requestLayerHandledNetworkError;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 4]);
return [4 /*yield*/, this._restoreFromToken(restoreToken)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_2 = _a.sent();
requestLayerHandledNetworkError = (0, errors_1.isConversationsError)(error_2) && error_2.kind === 'network' && 'cause' in error_2;
if (!requestLayerHandledNetworkError) {
logger.warn('Restore token exchange failed, retrying once', error_2);
}
return [4 /*yield*/, this._restoreFromToken(restoreToken)];
case 3: return [2 /*return*/, _a.sent()];
case 4: return [2 /*return*/];
}
});
});
};
ConversationsManager.prototype._restoreFromToken = function (restoreToken) {
return __awaiter(this, void 0, void 0, function () {
var token, payload, data;
var _this = this;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
token = this._config.token;
payload = {
restore_token: restoreToken,
widget_session_id: this._widgetSessionId,
distinct_id: this._posthog.get_distinct_id(),
current_url: this._currentUrl(),
};
return [4 /*yield*/, new Promise(function (resolve, reject) {
_this._posthog._send_request({
url: _this._posthog.requestRouter.endpointFor('api', RESTORE_EXCHANGE_ENDPOINT),
method: 'POST',
data: payload,
headers: {
'X-Conversations-Token': token,
},
callback: function (response) {
if (response.statusCode !== 200) {
reject(createConversationsRequestError(response, 'restoring conversations', 'Failed to restore conversations', true, false));
return;
}
if (!response.json) {
reject(createInvalidConversationsResponseError('restoring conversations', false));
return;
}
resolve(response.json);
},
});
})];
case 1:
data = _b.sent();
if (data.status !== 'success') {
logger.info('Restore token was not accepted', { status: data.status, code: data.code });
return [2 /*return*/, data];
}
this._lastMessageTimestamp = null;
this._unreadCount = 0;
// Apply the canonical widget_session_id from the server if provided
if (data.widget_session_id) {
this._widgetSessionId = data.widget_session_id;
this._persistence.setWidgetSessionId(data.widget_session_id);
}
if ((_a = data.migrated_ticket_ids) === null || _a === void 0 ? void 0 : _a.length) {
this._currentTicketId = data.migrated_ticket_ids[0];
this._persistence.saveTicketId(this._currentTicketId);
// Poll straight away so messages and ticket list are fresh, and resume
// the loop in case it had stopped while there was no conversation. At
// initial boot the widget isn't rendered yet — _doInitializeWidget starts
// the loop after restore completes, so only restart an already-live widget.
if (this._isWidgetRendered) {
this._startPolling();
}
void this._pollMessages();
void this._pollTickets();
}
else {
this._currentTicketId = null;
this._persistence.clearTicketId();
}
return [2 /*return*/, data];
}
});
});
};
/**
* Initialize and render the widget UI
* Uses a promise guard to prevent race conditions from concurrent calls
*/
ConversationsManager.prototype._initializeWidget = function () {
if (this._isWidgetRendered) {
return Promise.resolve();
}
if (this._initializeWidgetPromise) {
return this._initializeWidgetPromise;
}
this._initializeWidgetPromise = this._doInitializeWidget();
return this._initializeWidgetPromise;
};
ConversationsManager.prototype._doInitializeWidget = function () {
return __awaiter(this, void 0, void 0, function () {
var savedState, initialState, initialUserTraits, _a, initialView, tickets;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
savedState = this._persistence.loadWidgetState();
initialState = savedState === 'open' ? 'open' : 'closed';
this._widgetState = initialState;
initialUserTraits = this._getInitialUserTraits();
return [4 /*yield*/, this._determineInitialView()];
case 1:
_a = _b.sent(), initialView = _a.view, tickets = _a.tickets;
this._currentView = initialView;
// Render the widget with initial view
this._renderWidget(initialState, initialUserTraits, initialView, tickets);
this._isWidgetRendered = true;
this._posthog.capture('$conversations_widget_loaded', {
hasExistingTicket: !!this._currentTicketId,
initialState: initialState,
initialView: initialView,
ticketCount: tickets.length,
hasUserTraits: !!initialUserTraits,
});
// Start polling — the first poll fires immediately and loads messages or tickets
this._startPolling();
// Keep the widget attached across SPA navigations that replace document.body
this._startReattachWatcher();
return [2 /*return*/];
}
});
});
};
/**
* Extract name and email from PostHog's stored person properties.
*
* Person properties set via posthog.identify() are stored under the
* $stored_person_properties persistence key, not as top-level props.
* We check both locations plus the super-properties for completeness.
*/
ConversationsManager.prototype._getPersonTraits = function () {
var _a;
var superProps = ((_a = this._posthog.persistence) === null || _a === void 0 ? void 0 : _a.props) || {};
var storedPersonProps = this._posthog.get_property(constants_1.STORED_PERSON_PROPERTIES_KEY) || {};
var name = storedPersonProps.$name || storedPersonProps.name || superProps.$name || superProps.name || undefined;
var email = storedPersonProps.$email || storedPersonProps.email || superProps.$email || superProps.email || undefined;
return { name: name, email: email };
};
/**
* Get initial user traits from PostHog or localStorage
*/
ConversationsManager.prototype._getInitialUserTraits = function () {
var _a = this._getPersonTraits(), name = _a.name, email = _a.email;
if (name || email) {
return {
name: name || undefined,
email: email || undefined,
};
}
// Otherwise, check localStorage for previously saved traits
var savedTraits = this._persistence.loadUserTraits();
if (savedTraits && (savedTraits.name || savedTraits.email)) {
return savedTraits;
}
return null;
};
/**
* Mark messages as read
*/
ConversationsManager.prototype._markMessagesAsRead = function () {
return __awaiter(this, void 0, void 0, function () {
var response, error_3;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
if (!this._currentTicketId) {
return [2 /*return*/];
}
_b.label = 1;
case 1:
_b.trys.push([1, 3, , 4]);
return [4 /*yield*/, this.markAsRead(this._currentTicketId)];
case 2:
response = _b.sent();
this._unreadCount = response.unread_count;
// Update the widget to reflect the new unread count
(_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.setUnreadCount(0);
logger.info('Messages marked as read', { unreadCount: response.unread_count });
return [3 /*break*/, 4];
case 3:
error_3 = _b.sent();
if (!(0, errors_1.isConversationsError)(error_3)) {
logger.error('Failed to mark messages as read', error_3);
}
return [3 /*break*/, 4];
case 4: return [2 /*return*/];
}
});
});
};
/**
* Load messages for the current ticket
*/
ConversationsManager.prototype._loadMessages = function () {
return __awaiter(this, void 0, void 0, function () {
var identityBefore, ticketBefore, response, lastMessage, error_4;
var _a, _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
if (!this._currentTicketId) {
return [2 /*return*/];
}
_c.label = 1;
case 1:
_c.trys.push([1, 3, , 4]);
identityBefore = this._posthog.config.identity_distinct_id;
ticketBefore = this._currentTicketId;
return [4 /*yield*/, this.getMessages(this._currentTicketId, this._lastMessageTimestamp || undefined)
// Discard stale response if identity or ticket changed while in-flight
];
case 2:
response = _c.sent();
// Discard stale response if identity or ticket changed while in-flight
if (this._posthog.config.identity_distinct_id !== identityBefore ||
this._currentTicketId !== ticketBefore) {
return [2 /*return*/];
}
// Update unread count from response
if ((0, core_1.isNumber)(response.unread_count)) {
this._unreadCount = response.unread_count;
(_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.setUnreadCount(response.unread_count);
// If widget is open and there are unread messages, mark them as read
if (response.unread_count > 0 && this._isWidgetOpen()) {
this._markMessagesAsRead();
}
}
// Sync ticket_status so an agent-side resolve flips the UI to locked state even
// while we're polling messages (ticket polling doesn't run in messages view).
// Using ticketBefore (non-null, validated by the stale check above) avoids relying
// on TS narrowing of the mutable this._currentTicketId field.
if (response.ticket_status) {
this._applyTicketStatusUpdate(ticketBefore, response.ticket_status);
}
if (response.messages.length > 0) {
(_b = this._widgetRef) === null || _b === void 0 ? void 0 : _b.addMessages(response.messages);
lastMessage = response.messages[response.messages.length - 1];
this._lastMessageTimestamp = lastMessage.created_at;
}
return [3 /*break*/, 4];
case 3:
error_4 = _c.sent();
if (!(0, errors_1.isConversationsError)(error_4)) {
logger.error('Failed to load messages', error_4);
}
return [3 /*break*/, 4];
case 4: return [2 /*return*/];
}
});
});
};
ConversationsManager.prototype._isWidgetOpen = function () {
return this._widgetState === 'open';
};
/**
* Load tickets list from API
*/
ConversationsManager.prototype._loadTickets = function () {
return __awaiter(this, void 0, void 0, function () {
var response, totalUnread, error_5;
var _a, _b, _c;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
_d.trys.push([0, 2, , 3]);
return [4 /*yield*/, this.getTickets()];
case 1:
response = _d.sent();
this._tickets = response.results;
this._showTicketList = this._computeShowTicketList(response.results);
(_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.updateTickets(response.results, this._showTicketList);
totalUnread = response.results.reduce(function (sum, t) { return sum + (t.unread_count || 0); }, 0);
this._unreadCount = totalUnread;
(_b = this._widgetRef) === null || _b === void 0 ? void 0 : _b.setUnreadCount(totalUnread);
(_c = this._widgetRef) === null || _c === void 0 ? void 0 : _c.setCurrentTicketResolved(this._isCurrentTicketResolved());
logger.info('Tickets loaded', { count: response.results.length, totalUnread: totalUnread });
return [3 /*break*/, 3];
case 2:
error_5 = _d.sent();
if (!(0, errors_1.isConversationsError)(error_5)) {
logger.error('Failed to load tickets', error_5);
}
return [3 /*break*/, 3];
case 3: return [2 /*return*/];
}
});
});
};
ConversationsManager.prototype._computeShowTicketList = function (tickets) {
// Surface the ticket-list hub (which holds the "New conversation" button and
// the back-to-tickets navigation) whenever the user has any ticket -- including
// a single open one. Otherwise a user with one unresolved ticket is locked into
// that conversation with no way to start a second one.
return tickets.length >= 1;
};
ConversationsManager.prototype._isCurrentTicketResolved = function () {
var _this = this;
if (!this._currentTicketId) {
return false;
}
var ticket = this._tickets.find(function (t) { return t.id === _this._currentTicketId; });
return (ticket === null || ticket === void 0 ? void 0 : ticket.status) === 'resolved';
};
/**
* Patch the local _tickets cache with a new status for a given ticket and push
* any UI-relevant changes (resolved lock + list visibility) to the widget.
*/
ConversationsManager.prototype._applyTicketStatusUpdate = function (ticketId, status) {
var _a, _b;
var idx = this._tickets.findIndex(function (t) { return t.id === ticketId; });
if (idx === -1) {
return;
}
if (this._tickets[idx].status === status) {
return;
}
this._tickets = __spreadArray(__spreadArray(__spreadArray([], __read(this._tickets.slice(0, idx)), false), [
__assign(__assign({}, this._tickets[idx]), { status: status })
], false), __read(this._tickets.slice(idx + 1)), false);
this._showTicketList = this._computeShowTicketList(this._tickets);
(_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.updateTickets(this._tickets, this._showTicketList);
(_b = this._widgetRef) === null || _b === void 0 ? void 0 : _b.setCurrentTicketResolved(this._isCurrentTicketResolved());
};
ConversationsManager.prototype._trackPollingEndpointReachability = function (statusCode) {
this._consecutivePollingStatusZeroFailures = (0, request_utils_1.updateStatusZeroFailureCount)(statusCode, this._consecutivePollingStatusZeroFailures, MAX_CONSECUTIVE_POLLING_STATUS_ZERO_FAILURES, function () {
return logger.warn('Conversations polling requests are failing before receiving an HTTP response; this can happen due to network issues, CORS, browser blocking, or ad blockers. Stopped polling conversations; will try again when connectivity changes.');
});
};
/**
* Track HTTP 429s on polling requests so the loop can back off. Any 2xx
* clears the streak; a 429 lengthens the next poll delay exponentially.
*/
ConversationsManager.prototype._trackPollingRateLimit = function (statusCode) {
if (statusCode === 429) {
this._consecutivePollingRateLimitFailures += 1;
}
else if (statusCode >= 200 && statusCode < 300) {
this._consecutivePollingRateLimitFailures = 0;
}
};
/**
* Determine initial view based on ticket count
*/
ConversationsManager.prototype._determineInitialView = function () {
return __awaiter(this, void 0, void 0, function () {
var identityBefore, response, view, error_6;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
identityBefore = this._posthog.config.identity_distinct_id;
return [4 /*yield*/, this.getTickets()
// If identity changed while the request was in-flight, discard this
// stale response -- setIdentity/clearIdentity already triggered a
// fresh _loadTicketsAndReconcileView() with the correct credentials.
];
case 1:
response = _a.sent();
// If identity changed while the request was in-flight, discard this
// stale response -- setIdentity/clearIdentity already triggered a
// fresh _loadTicketsAndReconcileView() with the correct credentials.
if (this._posthog.config.identity_distinct_id !== identityBefore) {
return [2 /*return*/, { view: 'messages', tickets: [] }];
}
view = this._applyTicketsToState(response.results);
return [2 /*return*/, { view: view, tickets: response.results }];
case 2:
error_6 = _a.sent();
if (!(0, errors_1.isConversationsError)(error_6)) {
logger.error('Failed to determine initial view', error_6);
}
return [2 /*return*/, { view: 'messages', tickets: [] }];
case 3: return [2 /*return*/];
}
});
});
};
/**
* Apply a fetched ticket list to internal state and return the appropriate view.
* Shared by _determineInitialView (widget boot) and _loadTicketsAndReconcileView
* (identity change at runtime).
*/
ConversationsManager.prototype._applyTicketsToState = function (tickets) {
this._tickets = tickets;
this._showTicketList = this._computeShowTicketList(tickets);
var totalUnread = tickets.reduce(function (sum, t) { return sum + (t.unread_count || 0); }, 0);
this._unreadCount = totalUnread;
if (tickets.length >= 2) {
this._currentTicketId = null;
return 'tickets';
}
// Single resolved ticket: show list so user can start a new conversation instead of writing to it
if (tickets.length === 1 && tickets[0].status === 'resolved') {
this._currentTicketId = null;
this._persistence.clearTicketId();
return 'tickets';
}
if (tickets.length === 1) {
this._currentTicketId = tickets[0].id;
this._persistence.saveTicketId(tickets[0].id);
}
return 'messages';
};
/**
* Start the self-scheduling poll loop.
*
* Idempotent: safe to call from any state-changing handler (send, restore,
* identity change, ticket selection). The loop polls immediately, then
* reschedules itself with a delay derived from widget state and rate-limit
* backoff (see _nextPollDelayMs), and stops itself when there is nothing to
* poll (see _hasSomethingToPoll).
*/
ConversationsManager.prototype._startPolling = function () {
if (this._pollLoopRunning) {
return; // Already polling
}
this._pollLoopRunning = true;
var generation = ++this._pollGeneration;
logger.info('Started polling', { view: this._currentView });
void this._pollThenSchedule(generation);
};
/**
* Schedule the next poll, or stop the loop when there is nothing to poll.
* An idle widget with no conversation generates zero polling traffic.
*/
ConversationsManager.prototype._scheduleNextPoll = function (generation) {
var _this = this;
if (!this._pollLoopRunning || generation !== this._pollGeneration) {
return;
}
if (!this._hasSomethingToPoll()) {
this._pollTimeoutId = null;
this._pollLoopRunning = false;
logger.info('Nothing to poll, pausing until next user action');
return;
}
this._pollTimeoutId = globals_1.window === null || globals_1.window === void 0 ? void 0 : globals_1.window.setTimeout(function () { return void _this._pollThenSchedule(generation); }, this._nextPollDelayMs());
};
/**
* Delay before the next poll: exponential backoff while rate limited,
* otherwise the open/closed cadence.
*/
ConversationsManager.prototype._nextPollDelayMs = function () {
if (this._consecutivePollingRateLimitFailures > 0) {
return Math.min(POLL_RATE_LIMIT_BASE_MS * Math.pow(2, (this._consecutivePollingRateLimitFailures - 1)), POLL_RATE_LIMIT_MAX_MS);
}
return this._isWidgetOpen() ? POLL_INTERVAL_OPEN_MS : POLL_INTERVAL_CLOSED_MS;
};
/**
* Whether the current view has anything worth polling for. Messages view
* needs an active ticket; tickets view needs at least one ticket. The
* restore-request view never polls.
*/
ConversationsManager.prototype._hasSomethingToPoll = function () {
if (this._currentView === 'restore_request') {
return false;
}
if (this._currentView === 'messages') {
return !!this._currentTicketId;
}
return this._tickets.length > 0;
};
/**
* Stop the poll loop.
*/
ConversationsManager.prototype._stopPolling = function () {
if (this._pollTimeoutId != null) {
globals_1.window === null || globals_1.window === void 0 ? void 0 : globals_1.window.clearTimeout(this._pollTimeoutId);
this._pollTimeoutId = null;
logger.info('Stopped polling for messages');
}
this._pollLoopRunning = false;
// Invalidate any in-flight poll chain so it won't reschedule itself.
this._pollGeneration++;
};
/**
* Re-attach the widget container if it has been detached from the DOM.
*
* SPA frameworks such as Turbo Drive (Hotwire) and Rails Turbo replace the
* entire document.body on navigation. That removes our container — which was
* appended to body in _renderWidget — without any teardown call, so the
* widget silently disappears until a full page reload. Re-attaching the same
* element (rather than re-rendering) preserves widget state and avoids
* refetching messages.
*/
ConversationsManager.prototype._reattachWidgetIfDetached = function () {
// hide()/destroy() clear the container intentionally — don't fight them
if (!this._isWidgetRendered || !this._containerElement) {
return;
}
// Still in the DOM — nothing to do
if (this._containerElement.isConnected) {
return;
}
if (!(globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.body)) {
return;
}
globals_1.document.body.appendChild(this._containerElement);
logger.info('Re-attached widget after it was detached from the DOM');
};
/**
* Start watching for the widget being detached from the DOM (e.g. by an SPA
* navigation that replaces document.body) and re-attach it when it happens.
*/
ConversationsManager.prototype._startReattachWatcher = function () {
var _this = this;
if (this._reattachIntervalId) {
return; // Already watching
}
this._reattachIntervalId = globals_1.window === null || globals_1.window === void 0 ? void 0 : globals_1.window.setInterval(function () {
_this._reattachWidgetIfDetached();
}, REATTACH_CHECK_INTERVAL_MS);
};
/**
* Stop the DOM re-attach watcher.
*/
ConversationsManager.prototype._stopReattachWatcher = function () {
if (this._reattachIntervalId) {
globals_1.window === null || globals_1.window === void 0 ? void 0 : globals_1.window.clearInterval(this._reattachIntervalId);
this._reattachIntervalId = null;
}
};
/**
* Setup listener for identify events.
* When user calls posthog.identify(), hide the identification form
* since we now know who they are.
*/
ConversationsManager.prototype._setupIdentifyListener = function () {
var _this = this;
this._unsubscribeIdentifyListener = this._posthog.on('eventCaptured', function (event) {
var _a;
if (event.event === '$identify') {
// User just identified - hide the identification form if it's showing
(_a = _this._widgetRef) === null || _a === void 0 ? void 0 : _a.setUserIdentified();
}
});
};
/**
* Show the widget (render it to DOM).
* The widget respects its saved state (open/closed).
* Note: Domain restrictions still apply - widget won't render on disallowed domains.
*/
ConversationsManager.prototype.show = function () {
// Check domain restrictions - don't render on disallowed domains
if (!this._isDomainAllowed) {
logger.warn('Cannot show widget: current domain is not allowed');
return;
}
// If widget isn't rendered yet, render it now
if (!this._isWidgetRendered) {
this._initializeWidget();
}
};
/**
* Hide and remove the widget from the DOM.
* Conversation data is preserved - call show() to re-render.
*/
ConversationsManager.prototype.hide = function () {
// Stop polling when widget is hidden (save resources)
this._stopPolling();
// Stop re-attaching — the widget is being removed intentionally
this._stopReattachWatcher();
if (this._containerElement) {
(0, preact_1.render)(null, this._containerElement);
this._containerElement.remove();
this._containerElement = null;
}
this._widgetRef = null;
this._isWidgetRendered = false;
this._initializeWidgetPromise = null;
// Reset timestamp so show() will re-fetch all messages
this._lastMessageTimestamp = null;
};
/**
* Check if the widget is currently visible (rendered in DOM)
*/
ConversationsManager.prototype.isVisible = function () {
return this._isWidgetRendered;
};
/** Get tickets list for the current widget session or verified identity */
ConversationsManager.prototype.getTickets = function (options) {
return __awaiter(this, void 0, void 0, function () {
var token, identity, queryParams;
var _this = this;
var _a, _b;
return __generator(this, function (_c) {
token = this._config.token;
identity = this._identityFields();
queryParams = {
limit: String((_a = options === null || options === void 0 ? void 0 : options.limit) !== null && _a !== void 0 ? _a : 20),
offset: String((_b = options === null || options === void 0 ? void 0 : options.offset) !== null && _b !== void 0 ? _b : 0),
};
if (identity) {
queryParams.identity_distinct_id = identity.identity_distinct_id;
queryParams.identity_hash = identity.identity_hash;
}
else {
queryParams.widget_session_id = this._widgetSessionId;
}
if (options === null || options === void 0 ? void 0 : options.status) {
queryParams.status = options.status;
}
// eslint-disable-next-line compat/compat
return [2 /*return*/, new Promise(function (resolve, reject) {
_this._posthog._send_request({
url: _this._posthog.requestRouter.endpointFor('api', "/api/conversations/v1/widget/tickets?".concat((0, request_utils_1.formDataToQuery)(queryParams))),
method: 'GET',
timestampMode: 'query',
headers: {
'X-Conversations-Token': token,
},
callback: function (response) {
_this._trackPollingEndpointReachability(response.statusCode);
_this._trackPollingRateLimit(response.statusCode);
if (response.statusCode !== 200) {
reject(createConversationsRequestError(response, 'fetching tickets', 'Failed to fetch tickets'));
return;
}
if (!response.json) {
reject(createInvalidConversationsResponseError('fetching tickets'));
return;
}
var data = response.json;
resolve(data);
},
});
})];
});
});
};
ConversationsManager.prototype.requestRestoreLink = function (email) {
return __awaiter(this, void 0, void 0, function () {
var normalizedEmail, token, payload;
var _this = this;
return __generator(this, function (_a) {
if (this._identityFields()) {
return [2 /*return*/, { ok: true }];
}
normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail) {
throw new Error('Email is required');
}
token = this._config.token;
payload = {
email: normalizedEmail,
request_url: this._currentUrl() || '',
};
// eslint-disable-next-line compat/compat
return [2 /*return*/, new Promise(function (resolve, reject) {
_this._posthog._send_request({
url: _this._posthog.requestRouter.endpointFor('api', RESTORE_REQUEST_ENDPOINT),
method: 'POST',
data: payload,
headers: {
'X-Conversations-Token': token,
},
callback: function (response) {
if (response.statusCode !== 200) {
reject(createConversationsRequestError(response, 'requesting restore link', 'Failed to request restore link'));
return;
}
resolve({ ok: true });
},
});
})];
});
});
};
ConversationsManager.prototype.restoreFromToken = function (restoreToken) {
return __awaiter(this, void 0, void 0, function () {
var normalizedToken;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (this._identityFields()) {
return [2 /*return*/, { status: 'success' }];
}
normalizedToken = restoreToken.trim();
if (!normalizedToken) {
throw new Error('Restore token is required');
}
_a.label = 1;
case 1:
_a.trys.push([1, , 3, 4]);
return [4 /*yield*/, this._restoreFromTokenWithRetry(normalizedToken)];
case 2: return [2 /*return*/, _a.sent()];
case 3:
(0, url_utils_1.clearRestoreTokenFromUrl)();
return [7 /*endfinally*/];
case 4: return [2 /*return*/];
}
});
});
};
ConversationsManager.prototype.restoreFromUrlToken = function () {
return __awaiter(this, void 0, void 0, function () {
var restoreToken;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (this._identityFields()) {
return [2 /*return*/, null];
}
restoreToken = (0, url_utils_1.getRestoreTokenFromUrl)();
if (!restoreToken) {
return [2 /*return*/, null];
}
_a.label = 1;
case 1:
_a.trys.push([1, , 3, 4]);
return [4 /*yield*/, this.restoreFromToken(restoreToken)];
case 2: return [2 /*return*/, _a.sent()];
case 3:
(0, url_utils_1.clearRestoreTokenFromUrl)();
return [7 /*endfinally*/];
case 4: return [2 /*return*/];
}
});
});
};
/**
* Get the current active ticket ID
* Returns null if no conversation has been started yet
*/
ConversationsManager.prototype.getCurrentTicketId = function () {
return this._currentTicketId;
};
/**
* Get the widget session ID (persistent browser identifier)
* This ID is used for access control and stays the same across page loads
*/
ConversationsManager.prototype.getWidgetSessionId = function () {
return this._widgetSessionId;
};
ConversationsManager.prototype._identityFields = function () {
var id = this._posthog.config.identity_distinct_id;
var hash = this._posthog.config.identity_hash;
if (!id || !hash) {
return null;
}
return { identity_distinct_id: id, identity_hash: hash };
};
ConversationsManager.prototype.setIdentity = function () {
var _a;
this._resetConversationState();
(_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.setIdentityMode(true);
void this._loadTicketsAndReconcileView();
};
ConversationsManager.prototype.clearIdentity = function () {
var _a;
this._resetConversationState();
(_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.setIdentityMode(false);
void this._loadTicketsAndReconcileView();
};
ConversationsManager.prototype._resetConversationState = function () {
var _a;
this._currentTicketId = null;
this._persistence.clearTicketId();
this._lastMessageTimestamp = null;
(_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.clearMessages(true);
};
ConversationsManager.prototype._loadTicketsAndReconcileView = function () {
return __awaiter(this, void 0, void 0, function () {
var identityBefore, response, view, error_7;
var _a, _b, _c, _d;
return __generator(this, function (_e) {
switch (_e.label) {
case 0:
_e.trys.push([0, 2, , 3]);
identityBefore = this._posthog.config.identity_distinct_id;
return [4 /*yield*/, this.getTickets()];
case 1:
response = _e.sent();
if (this._posthog.config.identity_distinct_id !== identityBefore) {
return [2 /*return*/];
}
view = this._applyTicketsToState(response.results);
(_a = this._widgetRef) === null || _a === void 0 ? void 0 : _a.updateTickets(response.results, this._showTicketList);
(_b = this._widgetRef) === null || _b === void 0 ? void 0 : _b.setUnreadCount(this._unreadCount);
(_c = this._widgetRef) === null || _c === void 0 ? void 0 : _c.setCurrentTicketResolved(this._isCurrentTicketResolved());
this._currentView = view;
(_d = this._widgetRef) === null || _d === void 0 ? void 0 : _d.setView(view);
// Identity change may have surfaced (or cleared) conversations; resume
// the loop if it had stopped. It stops itself again if there's nothing to poll.
if (this._isWidgetRendered) {
this._startPolling();
}
if (view === 'messages' && this._currentTicketId) {
void this._loadMessages();
}
return [3 /*break*/, 3];
case 2:
error_7 = _e.sent();
if (!(0, errors_1.isConversationsError)(error_7)) {
logger.error('Failed to load tickets after identity change', error_7);
}
return [3 /*break*/, 3];
case 3: return [2 /*return*/];
}
});
});
};
/**
* Clean up the widget
*/
ConversationsManager.prototype.destroy = function () {
this._stopPolling();
this._stopReattachWatcher();
if (globals_1.window) {
globals_1.window.removeEventListener('online', this._onOnline);
}
// Unsubscribe from identify events
if (this._unsubscribeIdentifyListener) {
this._unsubscribeIdentifyListener();
this._unsubscribeIdentifyListener = null;
}
if (this._containerElement) {
(0, preact_1.render)(null, this._containerElement);
this._containerElement.remove();
this._containerElement = null;
}
this._widgetRef = null;
if (_activeManager === this) {
_activeManager = null;
}
logger.info('Widget destroyed');
};
/**
* Reset all conversation data and destroy the widget.
* Called on posthog.reset() to start fresh.
*/
ConversationsManager.prototype.reset = function () {
// Clear all persisted conversation data
this._persistence.clearAll();
// Reset local state
this._currentTicketId = null;
this._lastMessageTimestamp = null;
this._unreadCount = 0;
this._consecutivePollingStatusZeroFailures = 0;
// Destroy the widget
this.destroy();
logger.info('Conversations reset');
};
/**
* Render the widget to the DOM
*/
ConversationsManager.prototype._renderWidget = function (initialState, initialUserTraits, initialView, initialTickets) {
var _this = this;
if (initialView === void 0) { initialView = 'messages'; }
if (initialTickets === void 0) { initialTickets = []; }
if (!globals_1.document) {
logger.info('Conversations widget not rendered: Document not available');
return;
}
// Create container if it doesn't exist
var container = globals_1.document.getElementById(WIDGET_CONTAINER_ID);
if (!container) {
if (!globals_1.document.body) {
logger.info('Conversations widget not rendered: Document body not available yet');
return;
}
container = globals_1.document.createElement('div');
container.id = WIDGET_CONTAINER_ID;
globals_1.document.body.appendChild(container);
}
this._containerElement = container;
// Render widget with ref
(0, preact_1.render)((0, jsx_runtime_1.jsx)(ConversationsWidget_1.ConversationsWidget, { ref: function (ref) {
_this._widgetRef = ref;
}, config: this._config, initialState: initialState, initialUserTraits: initialUserTraits, isUserIdentified: this._posthog._isIdentified(), isIdentityMode: !(0, core_1.isNull)(this._identityFields()), initialView: initialView, initialTickets: initialTickets, showTicketList: this._showTicketList, onSendMessage: this._handleSendMessage, onStateChange: this._handleStateChange, onIdentify: this._handleIdentify, onRequestRestoreLink: this._handleRequestRestoreLink, onSelectTicket: this._handleSelectTicket, onNewConversation: this._handleNewConversation, onBackToTickets: this._handleBackToTickets, onViewChange: this._handleViewChange }), container);
};
return ConversationsManager;
}());
exports.ConversationsManager = ConversationsManager;
/**
* Initialize the conversations widget.
* This is the entry point called from the lazy-loaded bundle.
*
* Singleton guard: only one ConversationsManager per page. The toolbar's
* internal PostHog instance is excluded upstream (see loadIfEnabled), so
* this always belongs to the customer's main instance.
*/
function initConversations(config, posthog) {
if (_activeManager) {
return _activeManager;
}
_activeManager = new ConversationsManager(config, posthog);
return _activeManager;
}
//# sourceMappingURL=index.js.map