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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user