@hellotext/hellotext
Version:
Hellotext JavaScript Client
158 lines • 7.16 kB
JavaScript
// The teaser markup is already present in the document; this mixin only manages
// the runtime around it. Keeping the teaser policy here gives the controller one
// place to delegate click-to-open, the pre-conversation presentation, and timer
// cleanup. Message sending stays on the controller because it owns the API flow
// and optimistic customer bubble insertion.
export var useTeaser = controller => {
Object.assign(controller, {
// Called from `connect` after `usePopover` has given the controller `show`.
// The teaser is optional, so setup first prepares reusable lifecycle state,
// wires the click surface when present, and then lets eligibility decide
// whether this session may still show the presentation.
setupTeaser() {
this.teaserCycleTimeout = null;
this.teaserMessages = [];
this.boundOnTeaserClick = this.boundOnTeaserClick || this.onTeaserClick.bind(this);
if (!this.hasTeaserTarget) return;
this.teaserTarget.addEventListener('click', this.boundOnTeaserClick);
this.startTeaserPresentation();
},
// This is the shared teardown path for Stimulus disconnect. Cycling timers
// and DOM listeners both outlive the current call stack, so they need to be
// cancelled explicitly when the rendered widget leaves the page.
teardownTeaser() {
this.stopTeaserCycle();
if (this.hasTeaserTarget && this.boundOnTeaserClick) {
this.teaserTarget.removeEventListener('click', this.boundOnTeaserClick);
}
},
// The current DOM is the source of truth. Each setup pass collects the
// rendered teaser messages from the teaser surface itself, which keeps the
// target list small and avoids carrying stale nodes between presentations.
collectTeaserMessages() {
if (!this.hasTeaserTarget) return [];
return Array.from(this.teaserTarget.querySelectorAll('[data-teaser-message]'));
},
// The teaser is a one-time, pre-conversation presentation. It can run while
// the popover is closed, but any active conversation signal makes the teaser
// ineligible for the rest of this browser session.
startTeaserPresentation() {
this.stopTeaserCycle();
this.teaserMessages = this.collectTeaserMessages();
if (!this.hasTeaserTarget) return;
if (this.teaserMessages.length === 0) {
this.hideTeaser();
return;
}
if (this.openValue) {
this.dismissTeaserForSession();
return;
}
if (this.conversationIdValue || this.hasRenderedConversationMessages()) {
this.dismissTeaserForSession();
return;
}
if (this.teaserSeenForSession()) {
this.hideTeaser();
return;
}
this.teaserTarget.classList.remove('invisible');
this.showTeaserMessage(0);
if (this.teaserMessages.length < 2) return;
this.scheduleNextTeaserMessage(0);
},
// Delays belong to the currently visible message, so each teaser controls
// how long it remains on screen before the next one replaces it. The
// presentation stops after the last message instead of looping forever.
scheduleNextTeaserMessage(currentIndex) {
var nextIndex = currentIndex + 1;
if (nextIndex >= this.teaserMessages.length) return;
var currentMessage = this.teaserMessages[currentIndex];
var delay = this.teaserPresentationDelay(currentMessage);
this.teaserCycleTimeout = window.setTimeout(() => {
this.teaserCycleTimeout = null;
this.showTeaserMessage(nextIndex);
this.scheduleNextTeaserMessage(nextIndex);
}, delay);
},
// Visibility is managed with the existing `hidden` class for initially
// concealed teaser messages. This keeps JS from restructuring teaser markup.
showTeaserMessage(index) {
this.teaserMessages.forEach((message, messageIndex) => {
message.classList.toggle('hidden', messageIndex !== index);
});
},
// Safe to call even when no timer exists. Lifecycle hooks call this before
// starting, hiding, or tearing down the teaser so only one cycle can be alive.
stopTeaserCycle() {
if (this.teaserCycleTimeout === null || this.teaserCycleTimeout === undefined) return;
window.clearTimeout(this.teaserCycleTimeout);
this.teaserCycleTimeout = null;
},
// Invalid or missing delay values should not break teaser rendering. Treat
// them as zero, then use a small minimum so zero-delay presentations advance
// deliberately instead of flashing through every message in one frame.
teaserMessageDelay(message) {
var delay = Number(message.dataset.delaySeconds || 0);
return Number.isFinite(delay) ? delay : 0;
},
teaserPresentationDelay(message) {
return Math.max(this.teaserMessageDelay(message) * 1000, 250);
},
// A rendered transcript means the visitor is no longer in the pre-conversation
// state. The hidden template is deliberately excluded because it is not a live
// customer-visible message.
hasRenderedConversationMessages() {
var messages = [];
try {
messages = Array.from(this.messageTargets || []);
} catch (_error) {
messages = [];
}
return messages.some(message => message !== this.messageTemplateTarget);
},
teaserSeenKey() {
var teaserVersion = this.hasTeaserTarget ? this.teaserTarget.dataset.teaserVersion : '';
var versionSegment = teaserVersion ? ":".concat(teaserVersion) : '';
return "hellotext:webchat:".concat(this.idValue || this.element.id, ":teaser-seen").concat(versionSegment);
},
teaserSeenForSession() {
try {
return window.sessionStorage.getItem(this.teaserSeenKey()) === 'true';
} catch (_error) {
return false;
}
},
markTeaserSeenForSession() {
try {
window.sessionStorage.setItem(this.teaserSeenKey(), 'true');
} catch (_error) {
// Storage can be unavailable in locked-down browsers. The in-memory hide
// still keeps the active controller from showing the teaser again.
}
},
// Opening or sending ends the pre-conversation window for this browser
// session. Incoming message teasers are separate ephemeral content, so they
// use `hideTeaser`/`updateMessageTeaser` without writing this session flag.
dismissTeaserForSession() {
this.markTeaserSeenForSession();
this.hideTeaser();
},
// Stopping the timer before hiding prevents queued presentation steps from
// flipping hidden messages after the teaser surface has been dismissed.
hideTeaser() {
this.stopTeaserCycle();
if (this.hasTeaserTarget) {
this.teaserTarget.classList.add('invisible');
}
},
// A teaser surface click is a user request to open chat, but links inside the
// teaser already have native browser behavior. Leaving anchors alone keeps
// external URLs and `tel:` actions working without extra JS.
onTeaserClick(event) {
if (event.target.closest('a')) return;
this.dismissTeaserForSession();
this.show();
}
});
};