From 63db02211b94d570d5601cd40caa79425c7864dc Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:14:25 +0200 Subject: [PATCH 1/4] fix(chat): harden overlay interactions and notifications --- extension/background.js | 82 +++++++- extension/chat-overlay-contract.test.mjs | 48 +++++ extension/chat-overlay.js | 248 ++++++++++++++++++++--- extension/popup.html | 7 + extension/popup.js | 11 +- scripts/test-chat-settings.mjs | 7 +- 6 files changed, 361 insertions(+), 42 deletions(-) diff --git a/extension/background.js b/extension/background.js index 50ada81..7e9424b 100644 --- a/extension/background.js +++ b/extension/background.js @@ -398,7 +398,7 @@ async function getSettings() { // (username) must NEVER come from storage.sync — syncing them across devices // both leaks them and resurrects dead rooms on reinstall (a fresh install // has empty local storage but sync survives in the user's Google account). - const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']); + const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']); let username = data.username; if (!username) { username = generateUsername(); @@ -417,6 +417,7 @@ async function getSettings() { password: data.password || '', chatKey, chatEnabled: data.chatEnabled === true, + chatNotifications: data.chatNotifications !== false, username, sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode), mediaTitlePrivacyMode @@ -453,7 +454,7 @@ function emitEpisodeLobbyForCurrentPrivacy() { // otherwise be redistributed across devices and resurrected on reinstall). const LEGACY_SYNC_KEYS = [ 'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', - 'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username', + 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode' @@ -907,7 +908,11 @@ function showNotification(senderName, action) { displayName = getMessage('LABEL_YOU') || 'YOU'; } - const message = getMessage('TOAST_PEER_ACTION', { name: displayName, action: label }) + '.'; + const message = action === 'joined' + ? getMessage('TOAST_PEER_JOINED', { name: displayName }) + : action === 'left' + ? getMessage('TOAST_PEER_LEFT', { name: displayName }) + : getMessage('TOAST_PEER_ACTION', { name: displayName, action: label }) + '.'; chrome.notifications.create(`sync_${Date.now()}`, { type: 'basic', @@ -919,6 +924,43 @@ function showNotification(senderName, action) { }); } +function showChatNotification(displayName, text) { + chrome.storage.local.get(['chatNotifications', 'locale'], async (settings) => { + if (settings.chatNotifications === false) return; + + await loadLocale(settings.locale || getSystemLanguage()); + chrome.notifications.create(`chat_${Date.now()}`, { + type: 'basic', + iconUrl: 'icons/icon128.png', + title: getMessage('CHAT_TITLE') || 'KoalaSync', + message: `${displayName || getMessage('CHAT_TITLE') || 'Room Chat'}: ${text}`, + priority: 1 + }); + }); +} + +function chatActivityDisplayName(senderId) { + if (senderId === peerId) return ''; + const peer = currentRoom?.peers?.find(candidate => + (typeof candidate === 'object' ? candidate.peerId : candidate) === senderId + ); + return typeof peer === 'object' ? peer.username || senderId : senderId; +} + +function sendChatActivity(action, senderId, timestamp = Date.now()) { + if (!currentTabId || !serverSupportsChat()) return; + if (![EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE, 'joined', 'left'].includes(action)) return; + chrome.tabs.sendMessage(Number(currentTabId), { + type: 'CHAT_EVENT', + event: { + action, + senderId, + username: chatActivityDisplayName(senderId), + timestamp: Number.isFinite(timestamp) ? timestamp : Date.now() + } + }).catch(() => {}); +} + function scheduleReconnect() { if (reconnectTimer) return; @@ -1235,6 +1277,12 @@ async function handleServerEvent(event, data) { } }).catch(() => {}); } + if (received.senderId !== peerId) { + showChatNotification( + typeof senderPeer === 'object' ? senderPeer.username : received.senderId, + text + ); + } } catch (_) { if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn'); } @@ -1291,6 +1339,7 @@ async function handleServerEvent(event, data) { if (data.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); + sendChatActivity(event, data.senderId, data.actionTimestamp); updateLastAction(event, data.senderId); lastActionState.targetTime = data.targetTime !== undefined ? data.targetTime : data.currentTime; if (storageInitialized) chrome.storage.session.set({ lastActionState }); @@ -1348,6 +1397,7 @@ async function handleServerEvent(event, data) { if (data?.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); + sendChatActivity(event, data.senderId, data.actionTimestamp); // (The sender's state is updated below with everyone else) } @@ -1403,6 +1453,8 @@ async function handleServerEvent(event, data) { currentRoom.peers.push(createPeerData(data)); if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); + sendChatActivity('joined', data.peerId, Date.now()); + showNotification(data.username || data.peerId, 'joined'); // We were alone and now we're not — proactively push our // current playback state so the newcomer syncs immediately @@ -1416,6 +1468,9 @@ async function handleServerEvent(event, data) { } } } else if (data.status === 'left') { + const departedDisplayName = chatActivityDisplayName(data.peerId); + sendChatActivity('left', data.peerId, Date.now()); + showNotification(departedDisplayName, 'left'); currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId); if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); @@ -1577,6 +1632,7 @@ function executeForceSync() { emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq }); routeToContent(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq }); + sendChatActivity(EVENTS.FORCE_SYNC_EXECUTE, peerId, executionTimestamp); addLog('Force Sync Executed', 'success'); } @@ -2554,7 +2610,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); chrome.storage.onChanged.addListener((changes, area) => { - if (area !== 'local' || (!changes.roomId && !changes.chatKey && !changes.chatEnabled)) return; + if (area !== 'local') return; + if (changes.browserNotifications && currentTabId) { + chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + } + if (!changes.roomId && !changes.chatKey && !changes.chatEnabled) return; if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue); invalidateChatSession(); if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); @@ -2661,7 +2721,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { return; } const settings = await getSettings(); - const localeData = await chrome.storage.local.get(['locale']); + const localeData = await chrome.storage.local.get(['locale', 'browserNotifications']); await loadLocale(localeData.locale || getSystemLanguage()); const translated = key => { const value = getMessage(key); @@ -2672,6 +2732,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { enabled: settings.chatEnabled, hasKey: !!settings.chatKey, connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined), + eventNotifications: localeData.browserNotifications === true, peerId, roomId: currentRoom.roomId, strings: { @@ -2688,7 +2749,15 @@ async function handleAsyncMessage(message, sender, sendResponse) { tooLong: translated('CHAT_TOO_LONG'), sendFailed: translated('CHAT_SEND_FAILED'), empty: translated('CHAT_EMPTY'), - you: translated('LABEL_YOU') + you: translated('LABEL_YOU'), + eventPlay: translated('NOTIF_PLAY'), + eventPause: translated('NOTIF_PAUSE'), + eventSeek: translated('NOTIF_SEEK'), + eventForcePrepare: translated('NOTIF_FORCE_PREPARE'), + eventForceExecute: translated('NOTIF_FORCE_EXECUTE'), + eventAction: translated('TOAST_PEER_ACTION'), + eventJoined: translated('TOAST_PEER_JOINED'), + eventLeft: translated('TOAST_PEER_LEFT') } }); } else if (message.type === 'CHAT_SEND') { @@ -3127,6 +3196,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { }, FORCE_SYNC_TIMEOUT); } addToHistory(message.action, 'You'); + sendChatActivity(message.action, peerId, timestamp); const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK; if (isNonEssentialEvent && !hasOtherPeers) { diff --git a/extension/chat-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs index 8559857..782b5bc 100644 --- a/extension/chat-overlay-contract.test.mjs +++ b/extension/chat-overlay-contract.test.mjs @@ -52,6 +52,54 @@ describe('chat overlay contract', () => { expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))'); }); + it('blocks page playback shortcuts while the chat composer owns keyboard input', () => { + expect(overlaySource).toContain("for (const eventName of ['keydown', 'keyup', 'keypress'])"); + expect(overlaySource).toContain('window.addEventListener(eventName, stopPageKeyboardShortcut, true)'); + expect(overlaySource).toContain('path.includes(textarea)'); + expect(overlaySource).toContain('event.stopImmediatePropagation()'); + expect(overlaySource).toContain('window.removeEventListener(eventName, stopPageKeyboardShortcut, true)'); + }); + + it('keeps the launcher draggable independently from dock mode', () => { + expect(overlaySource).toContain("launcher.addEventListener('pointerdown'"); + expect(overlaySource).toContain("launcher.addEventListener('pointermove'"); + expect(overlaySource).toContain('layout.launcherX = launcherDrag.x + deltaX'); + expect(overlaySource).toContain('layout.launcherY = launcherDrag.y + deltaY'); + expect(overlaySource).toContain('suppressLauncherClick = launcherDrag.moved'); + expect(overlaySource).not.toMatch(/launcher\.style\.left = `\$\{layout\.x\}px`/); + }); + + it('reserves page space for real left and right dock modes', () => { + expect(overlaySource).toContain("const PAGE_DOCK_ATTRIBUTE = 'data-koalasync-chat-dock'"); + expect(overlaySource).toContain('padding-left: var(${PAGE_DOCK_WIDTH}) !important'); + expect(overlaySource).toContain('padding-right: var(${PAGE_DOCK_WIDTH}) !important'); + expect(overlaySource).toContain('document.documentElement.setAttribute(PAGE_DOCK_ATTRIBUTE, side)'); + expect(overlaySource).toContain('applyPageDock(layout.mode, dockWidth)'); + expect(overlaySource).toContain('clearPageDock()'); + }); + + it('counts unread remote messages on the bubble and clears the badge when opened', () => { + expect(overlaySource).toContain("const unreadBadge = element('span', 'unread')"); + expect(overlaySource).toContain('if (!opened && !own) setUnreadCount(unreadCount + 1)'); + expect(overlaySource).toMatch(/if \(opened\) \{\s*setUnreadCount\(0\)/); + expect(overlaySource).toContain("unreadBadge.classList.toggle('visible', unreadCount > 0)"); + expect(backgroundSource).toMatch(/received\.senderId !== peerId[\s\S]*showChatNotification\([\s\S]*senderPeer\.username[\s\S]*received\.senderId/); + expect(backgroundSource).toContain("chrome.notifications.create(`chat_${Date.now()}`"); + expect(backgroundSource).toContain("if (settings.chatNotifications === false) return"); + }); + + it('renders timestamped room activity and keeps activity notifications opt-in', () => { + expect(overlaySource).toContain("if (message?.type === 'CHAT_EVENT') appendEvent(message.event)"); + expect(overlaySource).toContain("const time = element('time', 'time')"); + expect(overlaySource).toContain("date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })"); + expect(overlaySource).toContain('if (!opened && context.eventNotifications) setUnreadCount(unreadCount + 1)'); + expect(backgroundSource).toContain("eventNotifications: localeData.browserNotifications === true"); + expect(backgroundSource).toContain("sendChatActivity(message.action, peerId, timestamp)"); + expect(backgroundSource).toContain("sendChatActivity(event, data.senderId, data.actionTimestamp)"); + expect(backgroundSource).toContain("sendChatActivity('joined', data.peerId, Date.now())"); + expect(backgroundSource).toContain("sendChatActivity('left', data.peerId, Date.now())"); + }); + it('guards async refresh/send work and clears all composer state on room reset', () => { expect(overlaySource).toContain('generation !== refreshGeneration'); expect(overlaySource).toContain('if (sending || !context?.enabled) return'); diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js index b97ce12..8dbbf03 100644 --- a/extension/chat-overlay.js +++ b/extension/chat-overlay.js @@ -9,6 +9,11 @@ const MIN_HEIGHT = 320; const DEFAULT_WIDTH = 360; const DEFAULT_HEIGHT = 520; + const LAUNCHER_SIZE = 48; + const EDGE_INSET = 8; + const DOCK_MIN_PAGE_WIDTH = 480; + const PAGE_DOCK_ATTRIBUTE = 'data-koalasync-chat-dock'; + const PAGE_DOCK_WIDTH = '--koalasync-chat-dock-width'; const SIZE_PRESETS = Object.freeze({ compact: Object.freeze({ width: 320, height: 400 }), standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }), @@ -26,6 +31,7 @@ let refreshGeneration = 0; let sendGeneration = 0; let sending = false; + let unreadCount = 0; let layout = { mode: 'right', x: 24, @@ -34,7 +40,10 @@ height: DEFAULT_HEIGHT, customWidth: DEFAULT_WIDTH, customHeight: DEFAULT_HEIGHT, - detachedInitialized: false + detachedInitialized: false, + launcherX: null, + launcherY: null, + launcherInitialized: false }; let chatPosition = 'right'; let chatSize = 'standard'; @@ -103,21 +112,31 @@ button, textarea { font: inherit; } button { color: inherit; } .launcher { - position: fixed; width: 48px; height: 48px; border: 1px solid var(--border-strong); - border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer; - pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32); font-size: 22px; - } - .launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); } - .launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; } - .panel { + position: fixed; width: 48px; height: 48px; border: 1px solid var(--border-strong); + border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer; + pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32); font-size: 22px; + touch-action: none; user-select: none; + } + .launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); } + .launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; } + .launcher-icon { pointer-events: none; } + .unread { + position: absolute; top: -7px; right: -7px; display: none; min-width: 22px; height: 22px; + align-items: center; justify-content: center; padding: 0 6px; border: 2px solid var(--card); + border-radius: 999px; background: var(--danger); color: white; font-size: 11px; font-weight: 800; + line-height: 1; pointer-events: none; + } + .unread.visible { display: flex; } + .panel { position: fixed; display: none; flex-direction: column; overflow: hidden; min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text); background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px; box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0); - } - .panel.open { display: flex; } - .header { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border-soft); user-select: none; } + } + .panel.open { display: flex; } + .panel.docked { border-radius: 0; max-height: 100vh; } + .header { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border-soft); user-select: none; } .header.detached { cursor: move; } .heading { min-width: 0; flex: 1; } .title { font-size: 14px; font-weight: 760; } @@ -131,7 +150,13 @@ .meta { display: flex; align-items: baseline; gap: 7px; margin-bottom: 2px; } .sender { color: var(--accent); font-weight: 700; font-size: 12px; } .time { color: var(--text-muted); font-size: 10px; } - .body { white-space: pre-wrap; color: var(--text); } + .body { white-space: pre-wrap; color: var(--text); } + .event-message { + display: flex; align-items: center; justify-content: center; gap: 7px; margin: 8px 0; + color: var(--text-muted); font-size: 11px; text-align: center; + } + .event-message::before, .event-message::after { content: ""; flex: 1; height: 1px; background: var(--border-soft); } + .event-text { max-width: 78%; } .composer { padding: 10px; border-top: 1px solid var(--border-soft); background: var(--card); } textarea { width: 100%; min-height: 62px; max-height: 132px; resize: vertical; border: 1px solid var(--border-strong); border-radius: 11px; padding: 9px; background: var(--surface-deep); color: var(--text); outline: none; } textarea:focus { border-color: var(--accent); } @@ -156,8 +181,12 @@ const app = element('div'); app.id = 'app'; - const launcher = element('button', 'launcher', '💬'); + const launcher = element('button', 'launcher'); launcher.type = 'button'; + const launcherIcon = element('span', 'launcher-icon', '💬'); + const unreadBadge = element('span', 'unread'); + unreadBadge.setAttribute('aria-hidden', 'true'); + launcher.append(launcherIcon, unreadBadge); const launcherHint = element('span', 'visually-hidden'); launcherHint.id = 'chat-launcher-hint'; const panel = element('section', 'panel'); @@ -169,7 +198,7 @@ heading.append(title, subtitle); const modes = element('div', 'modes'); const leftButton = element('button', 'icon-button', '←'); - const detachedButton = element('button', 'icon-button', '↗'); + const detachedButton = element('button', 'icon-button', '✥'); const rightButton = element('button', 'icon-button', '→'); const closeButton = element('button', 'icon-button', '×'); for (const button of [leftButton, detachedButton, rightButton, closeButton]) button.type = 'button'; @@ -199,6 +228,19 @@ app.append(launcherHint, launcher, panel); shadow.append(style, app); document.documentElement.append(host); + const pageDockStyle = document.createElement('style'); + pageDockStyle.id = 'koalasync-chat-page-dock-style'; + pageDockStyle.textContent = ` + html[${PAGE_DOCK_ATTRIBUTE}="left"] { + box-sizing: border-box !important; + padding-left: var(${PAGE_DOCK_WIDTH}) !important; + } + html[${PAGE_DOCK_ATTRIBUTE}="right"] { + box-sizing: border-box !important; + padding-right: var(${PAGE_DOCK_WIDTH}) !important; + } + `; + document.documentElement.append(pageDockStyle); function messageRuntime(payload, timeoutMs = 5000) { return new Promise(resolve => { @@ -250,6 +292,7 @@ detachedButton.setAttribute('aria-label', text.detached || ''); rightButton.setAttribute('aria-label', text.dockRight || ''); closeButton.setAttribute('aria-label', text.close || ''); + applyUnreadCount(); } function applyTheme() { @@ -277,6 +320,52 @@ }; } + function clampLauncher() { + const viewport = viewportBounds(); + const maxX = Math.max(0, viewport.width - LAUNCHER_SIZE - EDGE_INSET); + const maxY = Math.max(0, viewport.height - LAUNCHER_SIZE - EDGE_INSET); + layout.launcherX = Math.min(Math.max(Number(layout.launcherX) || EDGE_INSET, EDGE_INSET), maxX); + layout.launcherY = Math.min(Math.max(Number(layout.launcherY) || EDGE_INSET, EDGE_INSET), maxY); + } + + function initializeLauncher() { + if (layout.launcherInitialized) return; + const viewport = viewportBounds(); + layout.launcherX = chatPosition === 'left' + ? Math.min(16, Math.max(EDGE_INSET, viewport.width - LAUNCHER_SIZE - EDGE_INSET)) + : Math.max(EDGE_INSET, viewport.width - LAUNCHER_SIZE - 16); + layout.launcherY = Math.max(EDGE_INSET, Math.round(viewport.height / 2 - LAUNCHER_SIZE / 2)); + layout.launcherInitialized = true; + clampLauncher(); + } + + function clearPageDock() { + document.documentElement.removeAttribute(PAGE_DOCK_ATTRIBUTE); + document.documentElement.style.removeProperty(PAGE_DOCK_WIDTH); + } + + function applyPageDock(side, width) { + const viewport = viewportBounds(); + if (!opened || document.fullscreenElement || viewport.width - width < DOCK_MIN_PAGE_WIDTH) { + clearPageDock(); + return; + } + document.documentElement.setAttribute(PAGE_DOCK_ATTRIBUTE, side); + document.documentElement.style.setProperty(PAGE_DOCK_WIDTH, `${width}px`); + } + + function applyUnreadCount() { + unreadBadge.textContent = unreadCount > 99 ? '99+' : String(unreadCount); + unreadBadge.classList.toggle('visible', unreadCount > 0); + const openLabel = strings().open || ''; + launcher.setAttribute('aria-label', unreadCount > 0 ? `${openLabel} (${unreadCount})` : openLabel); + } + + function setUnreadCount(next) { + unreadCount = Math.max(0, Number.isFinite(next) ? Math.trunc(next) : 0); + applyUnreadCount(); + } + function clampDetached() { const viewport = viewportBounds(); const maxWidth = Math.max(1, Math.min(600, viewport.width - 16)); @@ -300,35 +389,37 @@ function applyLayout() { applyingLayout = true; const size = preferredSize(); + initializeLauncher(); + clampLauncher(); + launcher.style.left = `${layout.launcherX}px`; + launcher.style.right = 'auto'; + launcher.style.top = `${layout.launcherY}px`; panel.style.right = 'auto'; panel.style.left = 'auto'; panel.style.bottom = 'auto'; panel.style.resize = 'none'; + panel.classList.toggle('docked', layout.mode !== 'detached'); header.classList.toggle('detached', layout.mode === 'detached'); leftButton.classList.toggle('active', layout.mode === 'left'); detachedButton.classList.toggle('active', layout.mode === 'detached'); rightButton.classList.toggle('active', layout.mode === 'right'); if (layout.mode === 'detached') { + clearPageDock(); clampDetached(); panel.style.left = `${layout.x}px`; panel.style.top = `${layout.y}px`; panel.style.width = `${layout.width}px`; panel.style.height = `${layout.height}px`; panel.style.resize = 'both'; - launcher.style.left = `${layout.x}px`; - launcher.style.right = 'auto'; - launcher.style.top = `${layout.y}px`; } else { const viewport = viewportBounds(); - const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2))); - const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64; - panel.style.top = `${panelTop}px`; - panel.style.width = `${Math.max(1, Math.min(size.width, viewport.width - gutter * 2))}px`; - panel.style.height = `${Math.max(1, Math.min(size.height, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`; - panel.style[layout.mode] = `${gutter}px`; - launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`; - launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto'; - launcher.style.right = layout.mode === 'right' ? `${gutter}px` : 'auto'; + const dockWidth = Math.max(1, Math.min(size.width, viewport.width - 16)); + panel.style.top = '0'; + panel.style.bottom = '0'; + panel.style.width = `${dockWidth}px`; + panel.style.height = `${viewport.height}px`; + panel.style[layout.mode] = '0'; + applyPageDock(layout.mode, dockWidth); } globalThis.queueMicrotask(() => { applyingLayout = false; }); } @@ -369,9 +460,11 @@ panel.classList.toggle('open', opened); launcher.style.display = opened ? 'none' : ''; if (opened) { + setUnreadCount(0); textarea.focus(); messages.scrollTop = messages.scrollHeight; } + applyLayout(); } function applyContext(next) { @@ -413,6 +506,7 @@ const wrapper = element('article', 'message'); const meta = element('div', 'meta'); const own = message.senderId === context.peerId; + if (!opened && !own) setUnreadCount(unreadCount + 1); const sender = element('span', 'sender', own ? (strings().you || '') : (message.username || message.senderId || '')); const time = element('time', 'time'); const date = new Date(message.timestamp); @@ -431,8 +525,44 @@ messages.scrollTop = messages.scrollHeight; } + function appendEvent(event) { + if (!context?.enabled || !event || typeof event.action !== 'string') return; + const own = event.senderId === context.peerId; + const displayName = own ? (strings().you || '') : (event.username || event.senderId || ''); + const labels = { + play: strings().eventPlay, + pause: strings().eventPause, + seek: strings().eventSeek, + force_sync_prepare: strings().eventForcePrepare, + force_sync_execute: strings().eventForceExecute + }; + let eventText = ''; + if (event.action === 'joined' || event.action === 'left') { + const template = event.action === 'joined' ? strings().eventJoined : strings().eventLeft; + eventText = (template || '{name}').replace('{name}', displayName); + } else if (labels[event.action]) { + eventText = (strings().eventAction || '{name} {action}') + .replace('{name}', displayName) + .replace('{action}', labels[event.action]); + } + if (!eventText) return; + + if (empty.isConnected) empty.remove(); + if (!opened && context.eventNotifications) setUnreadCount(unreadCount + 1); + const wrapper = element('article', 'message event-message'); + const body = element('span', 'event-text', eventText); + const time = element('time', 'time'); + const date = new Date(event.timestamp); + time.textContent = Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; + wrapper.append(body, time); + messages.append(wrapper); + while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove(); + messages.scrollTop = messages.scrollHeight; + } + function clearMessages() { messages.replaceChildren(empty); + setUnreadCount(0); } function resetComposer() { @@ -445,7 +575,48 @@ sendButton.disabled = false; } - launcher.addEventListener('click', () => setOpened(true)); + let launcherDrag = null; + let suppressLauncherClick = false; + launcher.addEventListener('click', event => { + if (suppressLauncherClick && event.detail !== 0) { + suppressLauncherClick = false; + return; + } + suppressLauncherClick = false; + setOpened(true); + }); + launcher.addEventListener('pointerdown', event => { + launcherDrag = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + x: layout.launcherX, + y: layout.launcherY, + moved: false + }; + launcher.setPointerCapture(event.pointerId); + }); + launcher.addEventListener('pointermove', event => { + if (!launcherDrag || event.pointerId !== launcherDrag.pointerId) return; + const deltaX = event.clientX - launcherDrag.startX; + const deltaY = event.clientY - launcherDrag.startY; + if (Math.hypot(deltaX, deltaY) >= 4) launcherDrag.moved = true; + layout.launcherX = launcherDrag.x + deltaX; + layout.launcherY = launcherDrag.y + deltaY; + clampLauncher(); + launcher.style.left = `${layout.launcherX}px`; + launcher.style.top = `${layout.launcherY}px`; + }); + launcher.addEventListener('pointerup', event => { + if (!launcherDrag || event.pointerId !== launcherDrag.pointerId) return; + suppressLauncherClick = launcherDrag.moved; + launcherDrag = null; + saveLayout(); + }); + launcher.addEventListener('pointercancel', () => { + launcherDrag = null; + suppressLauncherClick = false; + }); closeButton.addEventListener('click', () => setOpened(false)); leftButton.addEventListener('click', () => setMode('left')); detachedButton.addEventListener('click', () => setMode('detached')); @@ -457,12 +628,18 @@ count.style.color = length > 500 ? 'var(--danger)' : ''; status.textContent = length > 500 ? (strings().tooLong || '') : ''; }); - textarea.addEventListener('keydown', event => { - if (event.key === 'Enter' && !event.shiftKey) { + const stopPageKeyboardShortcut = event => { + const path = typeof event.composedPath === 'function' ? event.composedPath() : []; + if (!path.includes(textarea) && !path.includes(composer)) return; + if (event.type === 'keydown' && event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); if (!sending) composer.requestSubmit(); } - }); + event.stopImmediatePropagation(); + }; + for (const eventName of ['keydown', 'keyup', 'keypress']) { + window.addEventListener(eventName, stopPageKeyboardShortcut, true); + } composer.addEventListener('submit', async event => { event.preventDefault(); if (sending || !context?.enabled) return; @@ -535,10 +712,11 @@ } function handleResize() { + clampLauncher(); + applyLayout(); + saveLayout(); if (layout.mode === 'detached') { clampDetached(); - applyLayout(); - saveLayout(); } } @@ -565,6 +743,7 @@ function handleRuntime(message) { if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message); + if (message?.type === 'CHAT_EVENT') appendEvent(message.event); if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh(); if (message?.type === 'CHAT_RESET') { clearMessages(); @@ -581,6 +760,11 @@ sendGeneration++; if (saveTimer) clearTimeout(saveTimer); resizeObserver.disconnect(); + clearPageDock(); + pageDockStyle.remove(); + for (const eventName of ['keydown', 'keyup', 'keypress']) { + window.removeEventListener(eventName, stopPageKeyboardShortcut, true); + } document.removeEventListener('fullscreenchange', moveIntoFullscreen); window.removeEventListener('resize', handleResize); systemTheme.removeEventListener('change', handleSystemTheme); diff --git a/extension/popup.html b/extension/popup.html index 0ed6cc2..58c7d8d 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -1632,6 +1632,13 @@ +
+ + +
diff --git a/extension/popup.js b/extension/popup.js index 7a3f777..6f765f3 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -69,6 +69,7 @@ const elements = { pauseBtn: document.getElementById('pauseBtn'), autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'), chatEnabled: document.getElementById('chatEnabled'), + chatNotifications: document.getElementById('chatNotifications'), chatPosition: document.getElementById('chatPosition'), chatSize: document.getElementById('chatSize'), chatStartMode: document.getElementById('chatStartMode'), @@ -341,7 +342,7 @@ function setRoomRefreshCooldown() { async function init() { // Local-only by design — settings and room credentials never come from // storage.sync (only onboardingComplete + dismissedHints live there). - const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']); + const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']); let activeLang = localData.locale; if (!activeLang) { @@ -373,6 +374,7 @@ async function init() { if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false; if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false; if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true; + if (elements.chatNotifications) elements.chatNotifications.checked = localData.chatNotifications !== false; if (elements.chatPosition) elements.chatPosition.value = ['left', 'detached'].includes(localData.chatPosition) ? localData.chatPosition : 'right'; if (elements.chatSize) elements.chatSize.value = ['compact', 'large', 'custom'].includes(localData.chatSize) ? localData.chatSize : 'standard'; if (elements.chatStartMode) elements.chatStartMode.value = localData.chatStartMode === 'open' ? 'open' : 'bubble'; @@ -1467,7 +1469,7 @@ elements.autoSyncNextEpisode.addEventListener('change', () => { function syncChatSettingsState() { const enabled = elements.chatEnabled?.checked === true; - for (const control of [elements.chatPosition, elements.chatSize, elements.chatStartMode]) { + for (const control of [elements.chatNotifications, elements.chatPosition, elements.chatSize, elements.chatStartMode]) { if (control) control.disabled = !enabled; } document.querySelectorAll('[data-chat-setting]').forEach(row => { @@ -1482,6 +1484,11 @@ if (elements.chatEnabled) { chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked }); }); } +if (elements.chatNotifications) { + elements.chatNotifications.addEventListener('change', () => { + chrome.storage.local.set({ chatNotifications: elements.chatNotifications.checked }); + }); +} if (elements.chatPosition) { elements.chatPosition.addEventListener('change', () => { const chatPosition = ['left', 'detached'].includes(elements.chatPosition.value) ? elements.chatPosition.value : 'right'; diff --git a/scripts/test-chat-settings.mjs b/scripts/test-chat-settings.mjs index 1f888ae..245098f 100644 --- a/scripts/test-chat-settings.mjs +++ b/scripts/test-chat-settings.mjs @@ -23,7 +23,7 @@ function detailsSection(marker) { const chatSection = detailsSection('data-i18n="CHAT_TITLE"'); const syncSection = detailsSection('data-i18n="LABEL_SETTINGS_GROUP_SYNC"'); -for (const id of ['chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode']) { +for (const id of ['chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode']) { assert.match(chatSection, new RegExp(`id="${id}"`), `${id} belongs in the dedicated chat settings section`); } assert.doesNotMatch(syncSection, /id="chatEnabled"/, 'chat enablement must not remain under Playback & Sync'); @@ -38,7 +38,7 @@ for (const value of ['bubble', 'open']) { assert.match(chatSection, new RegExp(`
- +
- +
diff --git a/website/alternatives/scener.html b/website/alternatives/scener.html index f56bb81..09ee73a 100644 --- a/website/alternatives/scener.html +++ b/website/alternatives/scener.html @@ -189,7 +189,7 @@ diff --git a/website/alternatives/teleparty.html b/website/alternatives/teleparty.html index 9bb3b48..01e7a35 100644 --- a/website/alternatives/teleparty.html +++ b/website/alternatives/teleparty.html @@ -189,7 +189,7 @@ diff --git a/website/alternatives/twoseven.html b/website/alternatives/twoseven.html index 9003f8c..006878b 100644 --- a/website/alternatives/twoseven.html +++ b/website/alternatives/twoseven.html @@ -189,7 +189,7 @@ diff --git a/website/alternatives/watch2gether.html b/website/alternatives/watch2gether.html index 5cc901f..4233cec 100644 --- a/website/alternatives/watch2gether.html +++ b/website/alternatives/watch2gether.html @@ -189,7 +189,7 @@ diff --git a/website/datenschutz-de.html b/website/datenschutz-de.html index aef5641..9cbe918 100644 --- a/website/datenschutz-de.html +++ b/website/datenschutz-de.html @@ -11,7 +11,7 @@ - +