customerio-gist-web
Version:
Build beautiful in-app flows with no code and deliver them instantly to your app. http://customer.io
247 lines (221 loc) • 8.75 kB
JavaScript
import Gist from '../gist';
import { log } from "../utilities/log";
import { getUserToken, isAnonymousUser } from "./user-manager";
import { getUserQueue, getQueueSSEEndpoint, userQueueNextPullCheckLocalStoreName } from "../services/queue-service";
import { showMessage, embedMessage } from "./message-manager";
import { resolveMessageProperties } from "./gist-properties-manager";
import { clearKeyFromLocalStore, getKeyFromLocalStore } from '../utilities/local-storage';
import { updateBroadcastsLocalStore, getEligibleBroadcasts, isShowAlwaysBroadcast } from './message-broadcast-manager';
import { updateQueueLocalStore, getMessagesFromLocalStore, isMessageLoading, setMessageLoading, getSavedMessageState } from './message-user-queue-manager';
import { updateInboxMessagesLocalStore } from './inbox-message-manager';
import { settings } from '../services/settings';
import { applyDisplaySettings } from '../utilities/message-utils';
var sleep = time => new Promise(resolve => setTimeout(resolve, time))
var poll = (promiseFn, time) => promiseFn().then(sleep(time).then(() => poll(promiseFn, time)));
var pollingSetup = false;
let sseSource = null;
export async function startQueueListener() {
if (!pollingSetup) {
if (getUserToken()) {
log("Queue watcher started");
pollingSetup = true;
poll(() => new Promise(() => pullMessagesFromQueue()), 1000);
} else {
log("User token not setup, queue not started.");
}
} else {
await checkMessageQueue();
}
}
export async function checkMessageQueue() {
var broadcastMessages = await getEligibleBroadcasts();
var userMessages = await getMessagesFromLocalStore();
var allMessages = broadcastMessages.concat(userMessages);
log(`Messages in local queue: ${allMessages.length}`);
var orderedMessages = allMessages.sort((a, b) => a.priority - b.priority);
for (const message of orderedMessages) {
await handleMessage(message);
}
}
//TODO: Move this to a utility and only return valid messages (from: getEligibleBroadcasts getMessagesFromLocalStore) & to handleMessage
async function handleMessage(message) {
var messageProperties = resolveMessageProperties(message);
if (messageProperties.hasRouteRule) {
var currentUrl = Gist.currentRoute;
if (currentUrl == null) {
currentUrl = new URL(window.location.href).pathname;
}
var routeRule = messageProperties.routeRule;
log(`Verifying route ${currentUrl} against rule: ${routeRule}`);
var urlTester = new RegExp(routeRule);
if (!urlTester.test(currentUrl)) {
log(`Route ${currentUrl} does not match rule.`);
return false;
}
}
if (messageProperties.hasPosition) {
message.position = messageProperties.position;
}
// Restore saved state for persistent messages or show-always broadcasts
if (messageProperties.persistent || isShowAlwaysBroadcast(message)) {
const savedState = await getSavedMessageState(message.queueId);
if (savedState) {
log(`Restoring saved state for queueId ${message.queueId}`);
// Apply saved display settings if they exist
if (savedState.displaySettings) {
applyDisplaySettings(message, savedState.displaySettings);
messageProperties = resolveMessageProperties(message); // Re-resolve after applying
}
// Store saved step for later use
message.savedStepName = savedState.stepName;
}
}
// If the message is not persistant, is not a show always broadcast, and is already loading, we skip it.
if (!messageProperties.persistent && !isShowAlwaysBroadcast(message) && await isMessageLoading(message.queueId)) {
log(`Not showing message with queueId ${message.queueId} because its already loading.`);
return false;
} else {
var loading = false;
if (messageProperties.isEmbedded) {
loading = await embedMessage(message, messageProperties.elementId);
} else {
loading = await showMessage(message);
}
if (loading) setMessageLoading(message.queueId);
return loading;
}
}
export async function pullMessagesFromQueue() {
// If SSE connection is already active, just check the local queue
if (settings.hasActiveSSEConnection()) {
// Close our SSE connection if we're not the main instance.
if (!settings.isSSEConnectionManagedBySDK() && sseSource) {
log("Not the main instance, closing our SSE connection.");
stopSSEListener();
}
await checkMessageQueue();
return;
} else {
if (sseSource) {
log("SSE connection not active, closing it.");
stopSSEListener();
}
}
// If SSE is enabled and user is not anonymous, set up SSE listener
if (settings.useSSE() && !isAnonymousUser()) {
await setupSSEQueueListener();
return;
}
// Fall back to polling
await checkQueueThroughPolling();
}
async function checkQueueThroughPolling() {
if (getUserToken()) {
if (Gist.isDocumentVisible) {
// We're using the TTL as a way to determine if we should check the queue, so if the key is not there, we check the queue.
if (getKeyFromLocalStore(userQueueNextPullCheckLocalStoreName) === null) {
var response = await getUserQueue();
if (response) {
if (response.status === 200 || response.status === 204) {
log("200 response, updating local store.");
var inAppMessages = response.data?.inAppMessages || [];
var inboxMessages = response.data?.inboxMessages || [];
updateQueueLocalStore(inAppMessages);
updateBroadcastsLocalStore(inAppMessages);
updateInboxMessagesLocalStore(inboxMessages);
} else if (response.status === 304) {
log("304 response, using local store.");
}
await checkMessageQueue();
} else {
log(`There was an error while checking message queue.`);
}
} else {
log(`Next queue pull scheduled for later.`);
}
} else {
log(`Document not visible, skipping queue check.`);
}
} else {
log(`User token reset, skipping queue check.`);
}
}
async function setupSSEQueueListener() {
// Close any existing SSE connection.
stopSSEListener();
// Get the SSE endpoint.
const sseURL = getQueueSSEEndpoint();
if (sseURL === null) {
log("SSE endpoint not available, falling back to polling.");
await checkQueueThroughPolling();
return;
}
log(`Starting SSE queue listener on ${sseURL}`);
sseSource = new EventSource(sseURL);
settings.setActiveSSEConnection();
sseSource.addEventListener("connected", async (event) => {
try {
log("SSE connection received:", event);
settings.setUseSSEFlag(true);
var config = JSON.parse(event.data);
if(config.heartbeat) {
settings.setSSEHeartbeat(config.heartbeat);
log(`SSE heartbeat set to ${config.heartbeat} seconds`);
}
settings.setActiveSSEConnection();
} catch (e) {
log(`Failed to parse SSE settings: ${e}`);
}
// On successful SSE connection, pull the queue.
clearKeyFromLocalStore(userQueueNextPullCheckLocalStoreName);
await checkQueueThroughPolling();
});
sseSource.addEventListener("messages", async (event) => {
try {
var messages = JSON.parse(event.data);
log("SSE message received:", messages);
await updateQueueLocalStore(messages);
await updateBroadcastsLocalStore(messages);
await checkMessageQueue();
} catch (e) {
log("Failed to parse SSE message", e);
stopSSEListener();
}
});
sseSource.addEventListener("inbox_messages", async (event) => {
try {
var inboxMessages = JSON.parse(event.data);
log("SSE inbox messages received:", inboxMessages);
await updateInboxMessagesLocalStore(inboxMessages);
} catch (e) {
log("Failed to parse SSE inbox messages", e);
}
});
sseSource.addEventListener("error", async (event) => {
log("SSE error received:", event);
stopSSEListener();
});
sseSource.addEventListener("heartbeat", async (event) => {
log("SSE heartbeat received:", event);
settings.setActiveSSEConnection();
settings.setUseSSEFlag(true);
});
}
export function stopSSEListener(disconnectGlobally = false) {
// When logging out, we need every instance to disconnect.
if (disconnectGlobally) {
settings.removeActiveSSEConnection();
}
// Update settings to reflect disconnected state
if (disconnectGlobally || settings.isSSEConnectionManagedBySDK()) {
settings.setUseSSEFlag(false);
}
// No active SSE connection to stop
if (!sseSource) {
return;
}
// Close the connection and clean up
log("Stopping SSE queue listener...");
sseSource.close();
sseSource = null;
}