diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4267f7c..bcf1998 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to the KoalaSync browser extension and relay server. --- -## Unreleased +## [v3.0.2] — 2026-07-31 ### Added - **Extension: Quick reactions** — Adds six encrypted one-click reactions with a local choice between chat-only display and bounded falling reactions over the video. @@ -13,6 +13,11 @@ All notable changes to the KoalaSync browser extension and relay server. ### Changed - **Extension: Real dock behavior** — Anchors the collapsed Koala chat launcher to the selected side, reserves a page column in normal and fullscreen layouts, and clips viewport-bound page layers away from the open dock; detached mode remains freely movable. - **Extension: Koala chat launcher** — Replaces the generic speech-bubble emoji with the KoalaSync extension icon plus a chat marker. +- **Extension: Reliable long chat sends** — Accepts up to 5000 Unicode code points, splits them into at most ten compatible 500-codepoint messages, waits for each relay echo before clearing text, and suppresses chat notifications while the selected video tab is focused. + +### Fixed +- **Extension: Strict target-tab lifecycle** — Fully deactivates playback, audio, heartbeat, observer, seek-bridge, and chat code before changing targets, cleans up superseded injections, and limits website-bridge status delivery to the KoalaSync site instead of broadcasting to every open tab. +- **Extension: Tab switching and page restore** — Keeps ordinary tab changes idle and injection-free, makes selecting the same target idempotent, blocks delayed callbacks after teardown, and restores the existing target cleanly after Firefox back/forward-cache suspension. ## [v3.0.1] — 2026-07-26 diff --git a/docs/CHAT.md b/docs/CHAT.md index 66b20d1..3ea550c 100644 --- a/docs/CHAT.md +++ b/docs/CHAT.md @@ -49,7 +49,9 @@ the stamped value as AAD. ## Client policy -- Maximum plaintext length: 500 Unicode code points. +- Maximum plaintext length per relay message: 500 Unicode code points. +- The composer accepts up to 5000 Unicode code points and sends them sequentially + as at most ten independently encrypted `chat-v1` messages. - Decrypted text is untrusted. Escape HTML before applying the supported limited Markdown formatting. - Quick reactions use the same encrypted message path as text. They remain readable @@ -84,6 +86,10 @@ the stamped value as AAD. - Chat display is a local option and defaults to off. Enabling or disabling it never deletes the room chat secret, so it can be enabled later without creating a room. - Without the relay `chat` capability, no chat control is shown. +- System notifications are suppressed while the selected video tab and its browser + window are focused. +- A send succeeds only after the relay echoes the authenticated ciphertext back to + the sender. Unconfirmed and remaining split messages stay in the composer. ## Mixed-version rollout diff --git a/extension/background.js b/extension/background.js index f98c46c..c945667 100644 --- a/extension/background.js +++ b/extension/background.js @@ -6,7 +6,7 @@ import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, norm import { initTabManager } from './modules/tab-manager.js'; import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js'; import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js'; -import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js'; +import { createChatEchoTracker, createChatSendLimiter, createLatestTaskQueue, normalizeRoomId, shouldShowChatNotification } from './chat-session.js'; import { createChatActivityStore } from './chat-activity.js'; import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; import './page-api-seek-overrides.js'; @@ -90,6 +90,7 @@ let chatSecretGuard = ''; let chatSessionGeneration = 0; let chatReceiveQueue = Promise.resolve(); const chatSendLimiter = createChatSendLimiter(); +const chatEchoTracker = createChatEchoTracker(); const chatActivityStore = createChatActivityStore(); const webJoinCoordinator = createLatestTaskQueue(); function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); } @@ -102,6 +103,7 @@ function invalidateChatSession() { chatSessionGeneration++; chatReceiveQueue = Promise.resolve(); chatSendLimiter.reset(); + chatEchoTracker.reset(); clearChatKeyCache(); } @@ -589,7 +591,7 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) completeForceSyncBeforeTargetChange(null); invalidateTargetActivations(); clearPendingTarget().catch(() => {}); - if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); + if (currentTabId) deactivateTargetTab(currentTabId).catch(() => {}); currentTabId = null; currentTabTitle = null; lastContentHeartbeatAt = null; @@ -625,7 +627,7 @@ async function leaveRoomAfterIdleGrace(reason) { // Notify content.js/popup BEFORE currentTabId is cleared so they can reset // any stale guest-side HCM state (dialog/badge/desync) — H-2. broadcastControlMode(); - if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); + if (currentTabId) await deactivateTargetTab(currentTabId); invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; @@ -873,6 +875,21 @@ function broadcastConnectionStatus(status) { updateBadgeStatus(); } +async function broadcastJoinStatus(message, shouldSend = () => true) { + let websiteTabs = []; + try { + websiteTabs = await chrome.tabs.query({ url: 'https://sync.koalastuff.net/*' }); + } catch (_) { + // The website bridge is optional and may be unavailable. + } + if (!shouldSend()) return false; + chrome.runtime.sendMessage(message).catch(() => {}); + await Promise.all(websiteTabs.map(tab => + chrome.tabs.sendMessage(tab.id, message).catch(() => {}) + )); + return true; +} + function updateBadgeStatus() { const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined; const isReconnecting = !isConnected && reconnectAttempts > 0; @@ -934,9 +951,42 @@ function showNotification(senderName, action) { }); } +function getTabForNotification(tabId) { + return new Promise(resolve => { + try { + chrome.tabs.get(tabId, tab => { + if (chrome.runtime.lastError) resolve(null); + else resolve(tab || null); + }); + } catch (_) { + resolve(null); + } + }); +} + +function getWindowForNotification(windowId) { + return new Promise(resolve => { + try { + chrome.windows.get(windowId, windowInfo => { + if (chrome.runtime.lastError) resolve(null); + else resolve(windowInfo || null); + }); + } catch (_) { + resolve(null); + } + }); +} + function showChatNotification(displayName, text) { chrome.storage.local.get(['chatNotifications', 'locale'], async (settings) => { - if (settings.chatNotifications === false) return; + const enabled = settings.chatNotifications !== false; + if (!enabled) return; + const targetTabId = normalizeTabId(currentTabId); + const tab = targetTabId === null ? null : await getTabForNotification(targetTabId); + const windowInfo = Number.isInteger(tab?.windowId) + ? await getWindowForNotification(tab.windowId) + : null; + if (!shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo })) return; await loadLocale(settings.locale || getSystemLanguage()); chrome.notifications.create(`chat_${Date.now()}`, { @@ -1233,12 +1283,7 @@ async function handleServerEvent(event, data) { // Inform Website Bridge & Popup const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' }; - chrome.runtime.sendMessage(joinStatusMsg).catch(() => {}); - chrome.tabs.query({}, (tabs) => { - tabs.forEach(tab => { - chrome.tabs.sendMessage(tab.id, joinStatusMsg).catch(() => {}); - }); - }); + await broadcastJoinStatus(joinStatusMsg); break; case EVENTS.CONTROL_MODE: // Host Control Mode changed (toggle or host-leave fallback). @@ -1279,6 +1324,7 @@ async function handleServerEvent(event, data) { secret: chatKey }); if (!isCurrentSession() || chatSecretGuard !== chatKey) return; + if (received.senderId === peerId) chatEchoTracker.acknowledge(received.ciphertext); const senderPeer = currentRoom.peers?.find(candidate => (typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId ); @@ -1333,12 +1379,7 @@ async function handleServerEvent(event, data) { }); // Inform Website Bridge & Popup const errStatusMsg = { type: 'JOIN_STATUS', success: false, message: data.message }; - chrome.runtime.sendMessage(errStatusMsg).catch(() => {}); - chrome.tabs.query({}, (tabs) => { - tabs.forEach(tab => { - chrome.tabs.sendMessage(tab.id, errStatusMsg).catch(() => {}); - }); - }); + await broadcastJoinStatus(errStatusMsg); break; case EVENTS.PLAY: case EVENTS.PAUSE: @@ -1896,8 +1937,12 @@ function shouldUsePageApiSeek(url) { } function installPageApiSeekBridge() { - if (window.__koalaPageApiSeekBridgeInstalled) return; - window.__koalaPageApiSeekBridgeInstalled = true; + if (window.__koalaPageApiSeekBridge?.activate) { + window.__koalaPageApiSeekBridge.activate(); + return; + } + + let active = true; function currentMatch() { return typeof window.koalaFindPageApiSeekProvider === 'function' @@ -1933,17 +1978,23 @@ function installPageApiSeekBridge() { } } - window.addEventListener('message', (event) => { + function handleBridgeMessage(event) { if (event.source !== window) return; const data = event.data; - if (!data || data.__koalaPageApiSeek !== 1 || data.kind !== 'seek' || typeof data.time !== 'number') return; + if (!data || data.__koalaPageApiSeek !== 1) return; + if (data.kind === 'destroy') { + destroy(); + return; + } + if (!active || data.kind !== 'seek' || typeof data.time !== 'number') return; seekWithPageApi(data.time); - }); + } // Disney+'s