('.bbp-sheet__close')!;
const state: State = 'closed';
return {
root: host,
fab,
sheet,
backdrop,
scroll,
composer,
textarea,
sendBtn,
closeBtn,
preview,
state,
pollTimer: null,
};
}
function setState(c: DrawerContext, next: State): void {
c.state = next;
c.sheet.setAttribute('data-state', next);
c.fab.setAttribute('aria-expanded', next === 'open' ? 'true' : 'false');
c.backdrop.setAttribute('data-open', next === 'open' ? 'true' : 'false');
c.backdrop.style.pointerEvents = next === 'open' ? 'auto' : 'none';
}
function findSiteChatNodes(): { scrollback: Element | null; input: HTMLTextAreaElement | null; sendBtn: HTMLButtonElement | null } {
const scrollback =
pick(SITE.chatScrollbackCandidates) ||
// fallback: any element containing > 3 // children with chat-like text
(() => {
const cands = Array.from(document.querySelectorAll('ul, ol, [role="list"], [role="log"]'));
return cands.find((c) => c.children.length > 2) ?? null;
})();
const input = pick(SITE.chatInputCandidates);
const sendBtn = pick(SITE.sendButtonCandidates);
return { scrollback, input, sendBtn };
}
function renderScrollback(c: DrawerContext, source: Element): void {
// Clone source children, strip their CSS classes but keep their text.
const frag = document.createDocumentFragment();
const children = Array.from(source.children).slice(-50);
for (const child of children) {
const div = document.createElement('div');
div.className = 'bbp-msg';
// Keep inner structure; chat users want basic formatting.
div.innerHTML = child.innerHTML;
frag.appendChild(div);
}
// Avoid clobbering user's scroll position if they've scrolled up
const atBottom =
c.scroll.scrollHeight - c.scroll.scrollTop - c.scroll.clientHeight < 80;
c.scroll.innerHTML = '';
c.scroll.appendChild(frag);
if (atBottom) c.scroll.scrollTop = c.scroll.scrollHeight;
// Update peek preview with last message text
const lastText = (children[children.length - 1]?.textContent ?? '').trim();
c.preview.textContent = lastText.length > 0 ? lastText : 'Say something…';
}
function startPolling(c: DrawerContext): void {
if (c.pollTimer) return;
c.pollTimer = window.setInterval(() => {
const { scrollback } = findSiteChatNodes();
if (scrollback && scrollback.children.length > 0) {
renderScrollback(c, scrollback);
} else {
c.scroll.innerHTML = '';
}
}, 1500);
}
function stopPolling(c: DrawerContext): void {
if (c.pollTimer) {
clearInterval(c.pollTimer);
c.pollTimer = null;
}
}
function sendViaSiteChat(_c: DrawerContext, text: string): boolean {
const { input, sendBtn } = findSiteChatNodes();
if (!input) return false;
// Set value via the React-friendly path (avoids React's value-caching issue)
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)?.set;
setter?.call(input, text);
input.dispatchEvent(new Event('input', { bubbles: true }));
// Prefer the site's own submit button. Fall back to Enter key.
if (sendBtn) {
sendBtn.click();
return true;
}
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }),
);
return true;
}
function wireEvents(c: DrawerContext): void {
c.fab.addEventListener('click', () => {
setState(c, c.state === 'closed' ? 'open' : 'closed');
if (c.state === 'open') {
startPolling(c);
c.textarea.focus();
} else {
stopPolling(c);
}
});
c.closeBtn.addEventListener('click', () => setState(c, 'closed'));
c.backdrop.addEventListener('click', () => setState(c, 'closed'));
// Tap on the peek-strip opens the sheet
c.preview.addEventListener('click', () => {
if (c.state === 'peek') setState(c, 'open');
});
// Drag handle: simple toggle between peek/open. Real swipe gesture is out of V1.
c.sheet.querySelector('.bbp-sheet__handle')!.addEventListener('click', () => {
if (c.state === 'closed') setState(c, 'peek');
else if (c.state === 'peek') setState(c, 'open');
else setState(c, 'closed');
if (c.state === 'open') startPolling(c);
else if (c.state === 'closed') stopPolling(c);
});
c.composer.addEventListener('submit', (e) => {
e.preventDefault();
const text = c.textarea.value.trim();
if (!text) return;
c.sendBtn.disabled = true;
const ok = sendViaSiteChat(c, text);
// Optimistic UX: clear input whether or not it actually lands.
c.textarea.value = '';
if (!ok) {
c.textarea.placeholder = 'Chat not available right now';
}
setTimeout(() => (c.sendBtn.disabled = false), 300);
});
// Auto-grow textarea on input
c.textarea.addEventListener('input', () => {
c.textarea.style.height = 'auto';
c.textarea.style.height = Math.min(c.textarea.scrollHeight, 120) + 'px';
});
}
export function mountDrawer(): void {
if (ctx) return;
ctx = buildDrawer();
wireEvents(ctx);
// Start in peek mode after a short delay so we don't flash during route load
setTimeout(() => {
if (ctx) setState(ctx, 'peek');
}, 800);
}
export function unmountDrawer(): void {
if (!ctx) return;
stopPolling(ctx);
ctx.fab.remove();
ctx.sheet.remove();
ctx.backdrop.remove();
ctx = null;
}