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
This commit is contained in:
Zebratic
2026-07-11 02:24:37 +00:00
commit 02a90e839c
20 changed files with 1835 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
/**
* 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;
}