Files
beat-pocket/extension/content/chat-drawer.ts
T
Zebratic 02a90e839c Initial scaffold: Beat Pocket MV3 extension
- manifest.json with MV3 config, host permissions for beat-battle.net
- content/detector.ts: route + overlay observer, emits bbp:phase events
- content/layout.ts: defensive mobile-first layout reflow
- content/chat-drawer.ts: bottom-sheet drawer, dispatches synthetic events
  through site's own React-managed textarea + button (preserves auth,
  rate-limit, mute, profanity guards)
- content/util/selectors.ts: text- and role-based fallback chain
- content/util/ws-proxy.ts: lightweight WebSocket instrumentation, no
  payload capture
- content/inject.css: mobile overrides using site's existing --bb-* vars
- background.js: MV3 service worker stub
- package.json + tsconfig.json + esbuild.config.mjs: build pipeline
- icons/: 16/32/48/128 placeholder PNGs (chili-red disc)
- docs/PLAN.md: full V1 architecture, risks, scope
- README.md: scope, dev workflow (load unpacked), tech
2026-07-11 02:24:37 +00:00

254 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* chat-drawer.ts — bottom sheet chat UI.
*
* Display:
* - We clone the existing chat scrollback (if found) and render into our
* own <ul>-style list. We update it every 1.5s by re-cloning the source.
* - We do NOT move the original node — we read-only display.
*
* Send (the clever bit):
* - Our composer dispatches a real InputEvent + KeyboardEvent(Enter) on
* the original site's chat textarea + a click on its submit button.
* - That keeps every server-side guard (rate limit, mute, profanity)
* intact — the React reducer thinks the user typed in their UI.
* - If the React flow is broken (e.g. their chat was redesigned), the WS
* proxy can fall back to a direct socket send.
*/
import { SITE, pick } from './util/selectors';
import { ensureHost } from './layout';
type State = 'closed' | 'peek' | 'open';
interface DrawerContext {
root: HTMLElement;
fab: HTMLButtonElement;
sheet: HTMLDivElement;
backdrop: HTMLDivElement;
scroll: HTMLDivElement;
composer: HTMLFormElement;
textarea: HTMLTextAreaElement;
sendBtn: HTMLButtonElement;
closeBtn: HTMLButtonElement;
preview: HTMLDivElement;
state: State;
pollTimer: number | null;
}
let ctx: DrawerContext | null = null;
function buildDrawer(): DrawerContext {
const host = ensureHost();
const fab = document.createElement('button');
fab.className = 'bbp-fab';
fab.setAttribute('aria-label', 'Open chat');
fab.setAttribute('aria-expanded', 'false');
fab.innerHTML = '💬<span class="bbp-fab__badge" hidden>0</span>';
const backdrop = document.createElement('div');
backdrop.className = 'bbp-backdrop';
backdrop.setAttribute('data-open', 'false');
const sheet = document.createElement('div');
sheet.className = 'bbp-sheet';
sheet.setAttribute('data-state', 'closed');
sheet.setAttribute('role', 'dialog');
sheet.setAttribute('aria-modal', 'false');
sheet.setAttribute('aria-label', 'Chat');
sheet.innerHTML = `
<div class="bbp-sheet__handle" aria-hidden="true"></div>
<div class="bbp-sheet__head">
<div class="bbp-sheet__title">Chat</div>
<button class="bbp-sheet__close" aria-label="Close chat">×</button>
</div>
<div class="bbp-sheet__peek-preview">Say something…</div>
<div class="bbp-sheet__scroll" aria-live="polite"></div>
<form class="bbp-composer">
<textarea placeholder="Type a message…" rows="1" aria-label="Chat message"></textarea>
<button type="submit" aria-label="Send">Send</button>
</form>
`;
host.appendChild(backdrop);
host.appendChild(sheet);
host.appendChild(fab);
const scroll = sheet.querySelector<HTMLDivElement>('.bbp-sheet__scroll')!;
const composer = sheet.querySelector<HTMLFormElement>('.bbp-composer')!;
const textarea = composer.querySelector<HTMLTextAreaElement>('textarea')!;
const sendBtn = composer.querySelector<HTMLButtonElement>('button')!;
const preview = sheet.querySelector<HTMLDivElement>('.bbp-sheet__peek-preview')!;
const closeBtn = sheet.querySelector<HTMLButtonElement>('.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 <p>/<span>/<div> 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<HTMLTextAreaElement>(SITE.chatInputCandidates);
const sendBtn = pick<HTMLButtonElement>(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;
}