From 0b6ae803c89b68cafe1f94f1080049c5d7c8f02f Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:31:28 +0200 Subject: [PATCH] feat: complete docked chat interactions --- docs/CHANGELOG.md | 10 ++ docs/CHAT.md | 16 +- docs/ROADMAP.md | 11 +- extension/background.js | 46 +++-- extension/chat-activity.js | 57 +++++++ extension/chat-activity.test.mjs | 32 ++++ extension/chat-overlay-contract.test.mjs | 49 +++++- extension/chat-overlay.js | 203 +++++++++++++++++++---- extension/locales/de.json | 5 + extension/locales/en.json | 5 + extension/locales/es.json | 5 + extension/locales/fr.json | 5 + extension/locales/it.json | 5 + extension/locales/ja.json | 5 + extension/locales/ko.json | 5 + extension/locales/nl.json | 5 + extension/locales/pl.json | 5 + extension/locales/pt-BR.json | 5 + extension/locales/pt.json | 5 + extension/locales/ru.json | 5 + extension/locales/tr.json | 5 + extension/locales/uk.json | 5 + extension/locales/zh.json | 5 + extension/popup.html | 9 + extension/popup.js | 11 +- scripts/build-extension.cjs | 4 +- scripts/test-chat-settings.mjs | 10 +- 27 files changed, 473 insertions(+), 60 deletions(-) create mode 100644 extension/chat-activity.js create mode 100644 extension/chat-activity.test.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4857a22..4267f7c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## Unreleased + +### 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. +- **Extension: Resilient chat activity** — Keeps a bounded per-room browser-session timeline for play, pause, seek, force-sync, join, and leave rows so overlay reinjection no longer loses them. + +### 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. + ## [v3.0.1] — 2026-07-26 ### Added diff --git a/docs/CHAT.md b/docs/CHAT.md index 4b0f6b4..66b20d1 100644 --- a/docs/CHAT.md +++ b/docs/CHAT.md @@ -52,6 +52,8 @@ the stamped value as AAD. - Maximum plaintext length: 500 Unicode code points. - 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 + by current chat clients without adding a relay-visible reaction event. - No read receipts and no typing indicators. - The local message DOM is bounded; this is presentation state, not server history. @@ -61,9 +63,21 @@ the stamped value as AAD. - Live modes: right, left, and detached. - Detached mode is draggable and moderately resizable. Size and position are stored per origin and clamped to the current viewport. -- The overlay uses a Shadow DOM and never changes host-page layout. +- Left and right modes anchor the launcher to the selected edge and reserve a + full-height page column while the panel is open. Detached mode is the only freely + movable launcher. +- Docking keeps a reserved column on narrow pages and in fullscreen instead of + silently falling back to an overlay. +- The overlay uses a Shadow DOM. Docking changes root-page or fullscreen-container + width and margins while the dock is open and clips viewport-bound page layers to + the remaining page column. The overrides stop applying when chat is detached or + closed, and the injected style element is removed when chat is destroyed. - On `fullscreenchange`, its host moves into `document.fullscreenElement` and remains visible. +- Six encrypted quick reactions are available from the composer. Users can keep them + in chat or additionally show a bounded falling-emoji animation over the video. +- Playback and room activity is retained in a bounded per-room browser-session + timeline so overlay reinjection does not lose command rows. It is not relay history. - It follows all eucalyptus, cyber, and graphite light/dark theme combinations. - Without a key, the panel stays closed and a disabled chat control explains that a current invite link is required. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8d346b3..580113d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -37,9 +37,10 @@ - **Category:** Social / Communication / Privacy - **Status:** Planned. This is a roadmap target, not a release announcement. - **Existing foundation:** Opt-in, live-only end-to-end encrypted room chat; a floating - chat bubble over the selected player opens a dockable, detachable, resizable overlay. - The relay stores no chat history and mixed extension versions use capability-gated - delivery. + Koala launcher over the selected player opens a dockable, detachable, resizable + overlay. Quick reactions can stay in chat or fall over the video, and a bounded + browser-session activity timeline survives overlay reinjection. The relay stores no + chat history and mixed extension versions use capability-gated delivery. #### Core experience @@ -57,8 +58,8 @@ #### Messaging -- Replies with quoted context, emoji reactions, emoji picker, mentions, and typing - indicators. +- Replies with quoted context, per-message reaction counters, a full emoji picker, + mentions, and typing indicators. - Edit and delete own messages with clear local tombstones; no silent mutation. - Delivery states (`sending`, `sent`, `failed`, `retry`) with idempotent retries and duplicate suppression after reconnects. diff --git a/extension/background.js b/extension/background.js index 7e9424b..f98c46c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -7,6 +7,7 @@ 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 { 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'; @@ -89,6 +90,7 @@ let chatSecretGuard = ''; let chatSessionGeneration = 0; let chatReceiveQueue = Promise.resolve(); const chatSendLimiter = createChatSendLimiter(); +const chatActivityStore = createChatActivityStore(); const webJoinCoordinator = createLatestTaskQueue(); function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); } function serverSupportsChat() { @@ -103,6 +105,11 @@ function invalidateChatSession() { clearChatKeyCache(); } +function clearChatActivity() { + chatActivityStore.clear(); + if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: [] }).catch(() => {}); +} + async function clearFailedJoinCredentials() { webJoinCoordinator.invalidate(); connectIntent = false; @@ -194,7 +201,7 @@ function ensureState() { 'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks', 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle', 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt', - 'hcmDesynced' + 'hcmDesynced', 'chatActivityTimeline' ], (data) => { clearTimeout(storageTimeout); if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount; @@ -215,6 +222,7 @@ function ensureState() { hostPeerId = currentRoom.hostPeerId || null; controllers = Array.isArray(currentRoom.controllers) ? currentRoom.controllers : []; serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : []; + chatActivityStore.restore(data.chatActivityTimeline); } if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced; // L-2: enforce the desync invariant on restore — a persisted hcmDesynced=true @@ -454,7 +462,7 @@ function emitEpisodeLobbyForCurrentPrivacy() { // otherwise be redistributed across devices and resurrected on reinstall). const LEGACY_SYNC_KEYS = [ 'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', - 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'username', + 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode' @@ -608,6 +616,7 @@ async function leaveRoomAfterIdleGrace(reason) { emit(EVENTS.LEAVE_ROOM, { peerId }); forceDisconnect(); currentRoom = null; + clearChatActivity(); controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; controllers = []; @@ -626,6 +635,7 @@ async function leaveRoomAfterIdleGrace(reason) { await clearPendingTarget(); await chrome.storage.session.set({ currentRoom: null, + chatActivityTimeline: [], currentTabId: null, currentTabTitle: null, roomIdleSince: null, @@ -948,17 +958,21 @@ function chatActivityDisplayName(senderId) { } function sendChatActivity(action, senderId, timestamp = Date.now()) { - if (!currentTabId || !serverSupportsChat()) return; + if (!currentRoom || !serverSupportsChat()) return; if (![EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE, 'joined', 'left'].includes(action)) return; + const entry = chatActivityStore.add({ + action, + senderId, + username: chatActivityDisplayName(senderId), + timestamp: Number.isFinite(timestamp) ? timestamp : Date.now() + }); + if (!entry) return; + if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: chatActivityStore.snapshot() }).catch(() => {}); + if (!currentTabId) return; chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_EVENT', - event: { - action, - senderId, - username: chatActivityDisplayName(senderId), - timestamp: Number.isFinite(timestamp) ? timestamp : Date.now() - } - }).catch(() => {}); + event: entry + }).catch(error => addLog(`Chat activity delivery failed: ${error.message}`, 'warn')); } function scheduleReconnect() { @@ -1156,7 +1170,10 @@ async function handleServerEvent(event, data) { } switch (event) { case EVENTS.ROOM_DATA: - if (currentRoom?.roomId !== data.roomId) invalidateChatSession(); + if (currentRoom?.roomId !== data.roomId) { + invalidateChatSession(); + clearChatActivity(); + } currentRoom = data; // Host Control Mode: adopt room role/mode on (re)join. controlMode = data.controlMode || CONTROL_MODES.EVERYONE; @@ -2556,6 +2573,7 @@ function leaveOldRoomIfSwitching(newRoomId) { addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info'); forceDisconnect(); currentRoom = null; + clearChatActivity(); controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; controllers = []; @@ -2735,6 +2753,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { eventNotifications: localeData.browserNotifications === true, peerId, roomId: currentRoom.roomId, + activity: chatActivityStore.snapshot(), strings: { title: translated('CHAT_TITLE'), liveOnly: translated('CHAT_LIVE_ONLY'), @@ -2757,7 +2776,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { eventForceExecute: translated('NOTIF_FORCE_EXECUTE'), eventAction: translated('TOAST_PEER_ACTION'), eventJoined: translated('TOAST_PEER_JOINED'), - eventLeft: translated('TOAST_PEER_LEFT') + eventLeft: translated('TOAST_PEER_LEFT'), + quickReactions: translated('CHAT_QUICK_REACTIONS') } }); } else if (message.type === 'CHAT_SEND') { @@ -2900,6 +2920,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { resetAudioProcessingInTab(currentTabId); emit(EVENTS.LEAVE_ROOM, { peerId }); currentRoom = null; + clearChatActivity(); controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; controllers = []; @@ -2928,6 +2949,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { chrome.storage.session.set({ currentRoom: null, + chatActivityTimeline: [], currentTabId: null, currentTabTitle: null, roomIdleSince: null, diff --git a/extension/chat-activity.js b/extension/chat-activity.js new file mode 100644 index 0000000..f0f5679 --- /dev/null +++ b/extension/chat-activity.js @@ -0,0 +1,57 @@ +const ALLOWED_ACTIONS = new Set([ + 'play', + 'pause', + 'seek', + 'force_sync_prepare', + 'force_sync_execute', + 'joined', + 'left' +]); + +export function createChatActivityStore(limit = 100) { + const maxEntries = Number.isInteger(limit) && limit > 0 ? limit : 100; + let entries = []; + + function normalize(value) { + if (!value || !ALLOWED_ACTIONS.has(value.action) || typeof value.senderId !== 'string' || !value.senderId) { + return null; + } + const timestamp = Number(value.timestamp); + if (!Number.isFinite(timestamp)) return null; + const id = typeof value.id === 'string' && value.id + ? value.id + : `${value.action}:${value.senderId}:${timestamp}`; + return { + id, + action: value.action, + senderId: value.senderId, + username: typeof value.username === 'string' ? value.username : '', + timestamp + }; + } + + return { + add(value) { + const entry = normalize(value); + if (!entry || entries.some(candidate => candidate.id === entry.id)) return null; + entries.push(entry); + if (entries.length > maxEntries) entries = entries.slice(-maxEntries); + return { ...entry }; + }, + clear() { + entries = []; + }, + restore(values) { + entries = []; + for (const value of Array.isArray(values) ? values : []) { + const entry = normalize(value); + if (!entry || entries.some(candidate => candidate.id === entry.id)) continue; + entries.push(entry); + } + entries = entries.slice(-maxEntries); + }, + snapshot() { + return entries.map(entry => ({ ...entry })); + } + }; +} diff --git a/extension/chat-activity.test.mjs b/extension/chat-activity.test.mjs new file mode 100644 index 0000000..9610038 --- /dev/null +++ b/extension/chat-activity.test.mjs @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { createChatActivityStore } from './chat-activity.js'; + +describe('chat activity store', () => { + it('deduplicates commands and retains only the newest bounded entries', () => { + const store = createChatActivityStore(2); + expect(store.add({ action: 'play', senderId: 'a', timestamp: 1 })).toMatchObject({ id: 'play:a:1' }); + expect(store.add({ action: 'play', senderId: 'a', timestamp: 1 })).toBeNull(); + store.add({ action: 'pause', senderId: 'b', username: 'Bear', timestamp: 2 }); + store.add({ action: 'seek', senderId: 'a', timestamp: 3 }); + + expect(store.snapshot()).toEqual([ + { id: 'pause:b:2', action: 'pause', senderId: 'b', username: 'Bear', timestamp: 2 }, + { id: 'seek:a:3', action: 'seek', senderId: 'a', username: '', timestamp: 3 } + ]); + }); + + it('restores only valid activity without sharing mutable references', () => { + const store = createChatActivityStore(); + store.restore([ + { id: 'joined:b:4', action: 'joined', senderId: 'b', username: 'Koala', timestamp: 4 }, + { action: 'unknown', senderId: 'b', timestamp: 5 }, + { action: 'left', senderId: '', timestamp: 6 } + ]); + + const snapshot = store.snapshot(); + snapshot[0].username = 'Changed'; + expect(store.snapshot()).toEqual([ + { id: 'joined:b:4', action: 'joined', senderId: 'b', username: 'Koala', timestamp: 4 } + ]); + }); +}); diff --git a/extension/chat-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs index 782b5bc..d72cbf9 100644 --- a/extension/chat-overlay-contract.test.mjs +++ b/extension/chat-overlay-contract.test.mjs @@ -7,6 +7,8 @@ const extensionDir = path.dirname(fileURLToPath(import.meta.url)); const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8'); const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8'); const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8'); +const manifestSource = fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8'); +const buildSource = fs.readFileSync(path.join(extensionDir, '..', 'scripts', 'build-extension.cjs'), 'utf8'); const localeDir = path.join(extensionDir, 'locales'); const chatKeys = [ 'LABEL_CHAT_ENABLED', @@ -23,7 +25,12 @@ const chatKeys = [ 'CHAT_MISSING_KEY', 'CHAT_TOO_LONG', 'CHAT_SEND_FAILED', - 'CHAT_EMPTY' + 'CHAT_EMPTY', + 'LABEL_CHAT_REACTION_DISPLAY', + 'LABEL_CHAT_REACTION_DISPLAY_TOOLTIP', + 'OPTION_CHAT_REACTIONS_CHAT', + 'OPTION_CHAT_REACTIONS_VIDEO', + 'CHAT_QUICK_REACTIONS' ]; describe('chat overlay contract', () => { @@ -60,20 +67,28 @@ describe('chat overlay contract', () => { expect(overlaySource).toContain('window.removeEventListener(eventName, stopPageKeyboardShortcut, true)'); }); - it('keeps the launcher draggable independently from dock mode', () => { + it('keeps the launcher draggable only in detached mode and anchors dock launchers', () => { expect(overlaySource).toContain("launcher.addEventListener('pointerdown'"); + expect(overlaySource).toMatch(/launcher\.addEventListener\('pointerdown'[\s\S]*layout\.mode !== 'detached'/); 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`/); + expect(overlaySource).toContain("launcher.classList.toggle('docked-left'"); + expect(overlaySource).toContain("launcher.classList.toggle('docked-right'"); }); 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('width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important'); + expect(overlaySource).toContain('margin-left: var(${PAGE_DOCK_WIDTH}) !important'); + expect(overlaySource).toContain('margin-right: var(${PAGE_DOCK_WIDTH}) !important'); + expect(overlaySource).toContain('html[${PAGE_DOCK_ATTRIBUTE}] > body'); + expect(overlaySource).toContain('clip-path: inset(0) !important'); + expect(overlaySource).toContain('> :not(#koalasync-chat-overlay-host)'); + expect(overlaySource).toContain('const target = document.fullscreenElement || document.documentElement'); + expect(overlaySource).toContain('pageDockTarget = target'); + expect(overlaySource).not.toContain('DOCK_MIN_PAGE_WIDTH'); expect(overlaySource).toContain('applyPageDock(layout.mode, dockWidth)'); expect(overlaySource).toContain('clearPageDock()'); }); @@ -98,12 +113,32 @@ describe('chat overlay contract', () => { 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())"); + expect(backgroundSource).toContain('activity: chatActivityStore.snapshot()'); + expect(overlaySource).toContain('renderedActivityIds.has(event.id)'); + expect(overlaySource).toContain('const MAX_RENDERED_ACTIVITY_IDS = 200'); + expect(overlaySource).toContain('while (renderedActivityIdOrder.length > MAX_RENDERED_ACTIVITY_IDS)'); + expect(overlaySource).toContain('renderedActivityIds.delete(renderedActivityIdOrder.shift())'); + }); + + it('uses the KoalaSync icon and offers bounded encrypted quick reactions', () => { + expect(overlaySource).toContain("const LAUNCHER_ICON_DATA_URL = 'data:image/png;base64,"); + expect(overlaySource).toContain('launcherIcon.src = LAUNCHER_ICON_DATA_URL'); + expect(manifestSource).not.toContain('web_accessible_resources'); + expect(overlaySource).toContain("const QUICK_REACTIONS = Object.freeze(['❤️', '😂', '😮', '😢', '👏', '🔥'])"); + expect(overlaySource).toContain("messageRuntime({ type: 'CHAT_SEND', text })"); + expect(overlaySource).toContain("chatReactionDisplay !== 'video'"); + expect(overlaySource).toContain('MAX_REACTION_PARTICLES - reactionLayer.childElementCount'); + expect(overlaySource).toContain('reducedMotion.matches'); + }); + + it('excludes test-only modules from production extension artifacts', () => { + expect(buildSource).toContain("/\\.test\\.[cm]?js$/u.test(item)"); }); 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'); - expect(overlaySource).toContain('textarea.value === submittedValue'); + expect(overlaySource).toContain("clearComposerValue && textarea.value.trim() === text"); expect(overlaySource).toMatch(/CHAT_RESET[\s\S]*resetComposer\(\)/); expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)'); expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then'); diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js index 8dbbf03..3e03c13 100644 --- a/extension/chat-overlay.js +++ b/extension/chat-overlay.js @@ -11,9 +11,12 @@ 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 QUICK_REACTIONS = Object.freeze(['❤️', '😂', '😮', '😢', '👏', '🔥']); + const MAX_REACTION_PARTICLES = 36; + const MAX_RENDERED_ACTIVITY_IDS = 200; + const LAUNCHER_ICON_DATA_URL = 'data:image/png;base64,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'; const SIZE_PRESETS = Object.freeze({ compact: Object.freeze({ width: 320, height: 400 }), standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }), @@ -48,8 +51,13 @@ let chatPosition = 'right'; let chatSize = 'standard'; let chatStartMode = 'bubble'; + let chatReactionDisplay = 'chat'; let themeMode = 'system'; let themePalette = 'eucalyptus'; + let pageDockTarget = null; + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + const renderedActivityIds = new Set(); + const renderedActivityIdOrder = []; const host = document.createElement('div'); host.id = 'koalasync-chat-overlay-host'; @@ -114,12 +122,22 @@ .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; + pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32); touch-action: none; user-select: none; } + .launcher.docked-left { left: 0 !important; right: auto !important; border-left: 0; border-radius: 0 16px 16px 0; } + .launcher.docked-right { left: auto !important; right: 0 !important; border-right: 0; border-radius: 16px 0 0 16px; } .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; } + .launcher-icon { display: block; width: 36px; height: 36px; margin: auto; pointer-events: none; } + .launcher-bubble-mark { + position: absolute; right: 3px; bottom: 3px; width: 15px; height: 12px; + border: 2px solid var(--card); border-radius: 7px; background: var(--accent); pointer-events: none; + } + .launcher-bubble-mark::after { + content: ""; position: absolute; right: 0; bottom: -4px; + width: 5px; height: 5px; background: var(--accent); clip-path: polygon(0 0, 100% 0, 100% 100%); + } .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); @@ -158,6 +176,13 @@ .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); } + .quick-reactions { display: flex; align-items: center; gap: 5px; margin-bottom: 8px; } + .reaction-button { + width: 34px; height: 30px; border: 1px solid var(--border-soft); border-radius: 9px; + background: var(--surface-alt); cursor: pointer; font-size: 18px; line-height: 1; + } + .reaction-button:hover:not(:disabled), .reaction-button:focus-visible { border-color: var(--accent); transform: translateY(-1px); } + .reaction-button:disabled { opacity: .5; cursor: wait; } 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); } textarea::placeholder { color: var(--text-muted); } @@ -167,9 +192,24 @@ .send { border: 0; border-radius: 10px; padding: 8px 13px; background: var(--accent); color: var(--text-on-green); font-weight: 750; cursor: pointer; } .send:hover:not(:disabled) { background: var(--accent-hover); } .send:disabled { opacity: .55; cursor: wait; } + .reaction-layer { position: fixed; inset: 0; overflow: hidden; pointer-events: none; z-index: 1; } + .reaction-particle { + position: absolute; top: -12vh; font-size: clamp(24px, 4vw, 48px); + filter: drop-shadow(0 4px 7px rgb(0 0 0 / .35)); + animation: reaction-fall var(--reaction-duration) cubic-bezier(.22,.62,.45,1) var(--reaction-delay) forwards; + } + .launcher, .panel { z-index: 2; } + @keyframes reaction-fall { + 0% { transform: translate3d(0, -8vh, 0) rotate(var(--reaction-start-rotation)); opacity: 0; } + 12% { opacity: 1; } + 100% { transform: translate3d(var(--reaction-drift), 116vh, 0) rotate(var(--reaction-end-rotation)); opacity: .92; } + } .visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } @media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } } - @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } + @media (prefers-reduced-motion: reduce) { + * { transition: none !important; } + .reaction-particle { animation: none !important; display: none !important; } + } `; function element(tag, className, text) { @@ -183,10 +223,14 @@ app.id = 'app'; const launcher = element('button', 'launcher'); launcher.type = 'button'; - const launcherIcon = element('span', 'launcher-icon', '💬'); + const launcherIcon = element('img', 'launcher-icon'); + launcherIcon.src = LAUNCHER_ICON_DATA_URL; + launcherIcon.alt = ''; + const launcherBubbleMark = element('span', 'launcher-bubble-mark'); + launcherBubbleMark.setAttribute('aria-hidden', 'true'); const unreadBadge = element('span', 'unread'); unreadBadge.setAttribute('aria-hidden', 'true'); - launcher.append(launcherIcon, unreadBadge); + launcher.append(launcherIcon, launcherBubbleMark, unreadBadge); const launcherHint = element('span', 'visually-hidden'); launcherHint.id = 'chat-launcher-hint'; const panel = element('section', 'panel'); @@ -209,6 +253,15 @@ const empty = element('div', 'empty'); messages.append(empty); const composer = element('form', 'composer'); + const quickReactions = element('div', 'quick-reactions'); + quickReactions.setAttribute('role', 'group'); + const reactionButtons = QUICK_REACTIONS.map(reaction => { + const button = element('button', 'reaction-button', reaction); + button.type = 'button'; + button.dataset.reaction = reaction; + quickReactions.append(button); + return button; + }); const textareaLabel = element('label', 'visually-hidden'); textareaLabel.htmlFor = 'chat-composer-input'; const textarea = element('textarea'); @@ -223,21 +276,51 @@ const status = element('div', 'status'); status.id = 'chat-composer-status'; status.setAttribute('role', 'status'); - composer.append(textareaLabel, textarea, composerRow, status); + composer.append(quickReactions, textareaLabel, textarea, composerRow, status); panel.append(header, messages, composer); - app.append(launcherHint, launcher, panel); + const reactionLayer = element('div', 'reaction-layer'); + reactionLayer.setAttribute('aria-hidden', 'true'); + app.append(reactionLayer, 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"] { + html[${PAGE_DOCK_ATTRIBUTE}] { box-sizing: border-box !important; - padding-left: var(${PAGE_DOCK_WIDTH}) !important; + width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important; + max-width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important; + overflow-x: clip !important; + } + html[${PAGE_DOCK_ATTRIBUTE}="left"] { + margin-left: var(${PAGE_DOCK_WIDTH}) !important; + margin-right: 0 !important; } html[${PAGE_DOCK_ATTRIBUTE}="right"] { + margin-left: 0 !important; + margin-right: var(${PAGE_DOCK_WIDTH}) !important; + } + html[${PAGE_DOCK_ATTRIBUTE}] > body { + width: 100% !important; + max-width: 100% !important; + min-height: 100vh !important; + overflow-x: clip !important; + clip-path: inset(0) !important; + } + [${PAGE_DOCK_ATTRIBUTE}]:not(html) > :not(#koalasync-chat-overlay-host) { box-sizing: border-box !important; - padding-right: var(${PAGE_DOCK_WIDTH}) !important; + width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important; + max-width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important; + overflow-x: clip !important; + clip-path: inset(0) !important; + } + [${PAGE_DOCK_ATTRIBUTE}="left"]:not(html) > :not(#koalasync-chat-overlay-host) { + margin-left: var(${PAGE_DOCK_WIDTH}) !important; + margin-right: 0 !important; + } + [${PAGE_DOCK_ATTRIBUTE}="right"]:not(html) > :not(#koalasync-chat-overlay-host) { + margin-left: 0 !important; + margin-right: var(${PAGE_DOCK_WIDTH}) !important; } `; document.documentElement.append(pageDockStyle); @@ -292,6 +375,12 @@ detachedButton.setAttribute('aria-label', text.detached || ''); rightButton.setAttribute('aria-label', text.dockRight || ''); closeButton.setAttribute('aria-label', text.close || ''); + quickReactions.setAttribute('aria-label', text.quickReactions || text.title || ''); + for (const button of reactionButtons) { + const label = `${text.quickReactions || text.title || ''} ${button.dataset.reaction}`.trim(); + button.title = label; + button.setAttribute('aria-label', label); + } applyUnreadCount(); } @@ -340,18 +429,24 @@ } function clearPageDock() { - document.documentElement.removeAttribute(PAGE_DOCK_ATTRIBUTE); - document.documentElement.style.removeProperty(PAGE_DOCK_WIDTH); + const targets = new Set([pageDockTarget, document.documentElement].filter(Boolean)); + for (const target of targets) { + target.removeAttribute(PAGE_DOCK_ATTRIBUTE); + target.style.removeProperty(PAGE_DOCK_WIDTH); + } + pageDockTarget = null; } function applyPageDock(side, width) { - const viewport = viewportBounds(); - if (!opened || document.fullscreenElement || viewport.width - width < DOCK_MIN_PAGE_WIDTH) { + if (!opened) { clearPageDock(); return; } - document.documentElement.setAttribute(PAGE_DOCK_ATTRIBUTE, side); - document.documentElement.style.setProperty(PAGE_DOCK_WIDTH, `${width}px`); + const target = document.fullscreenElement || document.documentElement; + if (pageDockTarget && pageDockTarget !== target) clearPageDock(); + pageDockTarget = target; + target.setAttribute(PAGE_DOCK_ATTRIBUTE, side); + target.style.setProperty(PAGE_DOCK_WIDTH, `${width}px`); } function applyUnreadCount() { @@ -389,11 +484,19 @@ 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`; + launcher.classList.toggle('docked-left', layout.mode === 'left'); + launcher.classList.toggle('docked-right', layout.mode === 'right'); + if (layout.mode === 'detached') { + initializeLauncher(); + clampLauncher(); + launcher.style.left = `${layout.launcherX}px`; + launcher.style.right = 'auto'; + launcher.style.top = `${layout.launcherY}px`; + } else { + launcher.style.left = layout.mode === 'left' ? '0' : 'auto'; + launcher.style.right = layout.mode === 'right' ? '0' : 'auto'; + launcher.style.top = `${Math.max(0, Math.round(window.innerHeight / 2 - LAUNCHER_SIZE / 2))}px`; + } panel.style.right = 'auto'; panel.style.left = 'auto'; panel.style.bottom = 'auto'; @@ -490,6 +593,9 @@ } applyStrings(); applyLayout(); + if (context?.enabled && Array.isArray(context.activity)) { + for (const event of context.activity) appendEvent(event); + } } async function refresh() { @@ -523,10 +629,12 @@ messages.append(wrapper); while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove(); messages.scrollTop = messages.scrollHeight; + if (QUICK_REACTIONS.includes(message.text.trim())) triggerReactionRain(message.text.trim()); } function appendEvent(event) { if (!context?.enabled || !event || typeof event.action !== 'string') return; + if (typeof event.id === 'string' && renderedActivityIds.has(event.id)) return; const own = event.senderId === context.peerId; const displayName = own ? (strings().you || '') : (event.username || event.senderId || ''); const labels = { @@ -546,6 +654,13 @@ .replace('{action}', labels[event.action]); } if (!eventText) return; + if (typeof event.id === 'string') { + renderedActivityIds.add(event.id); + renderedActivityIdOrder.push(event.id); + while (renderedActivityIdOrder.length > MAX_RENDERED_ACTIVITY_IDS) { + renderedActivityIds.delete(renderedActivityIdOrder.shift()); + } + } if (empty.isConnected) empty.remove(); if (!opened && context.eventNotifications) setUnreadCount(unreadCount + 1); @@ -562,9 +677,28 @@ function clearMessages() { messages.replaceChildren(empty); + renderedActivityIds.clear(); + renderedActivityIdOrder.length = 0; setUnreadCount(0); } + function triggerReactionRain(reaction) { + if (chatReactionDisplay !== 'video' || reducedMotion.matches || !QUICK_REACTIONS.includes(reaction)) return; + const available = Math.max(0, MAX_REACTION_PARTICLES - reactionLayer.childElementCount); + const particleCount = Math.min(10, available); + for (let index = 0; index < particleCount; index++) { + const particle = element('span', 'reaction-particle', reaction); + particle.style.left = `${4 + Math.random() * 88}%`; + particle.style.setProperty('--reaction-duration', `${2.8 + Math.random() * 1.6}s`); + particle.style.setProperty('--reaction-delay', `${Math.random() * .7}s`); + particle.style.setProperty('--reaction-drift', `${-45 + Math.random() * 90}px`); + particle.style.setProperty('--reaction-start-rotation', `${-35 + Math.random() * 70}deg`); + particle.style.setProperty('--reaction-end-rotation', `${-160 + Math.random() * 320}deg`); + particle.addEventListener('animationend', () => particle.remove(), { once: true }); + reactionLayer.append(particle); + } + } + function resetComposer() { sendGeneration++; sending = false; @@ -573,6 +707,7 @@ count.style.color = ''; status.textContent = ''; sendButton.disabled = false; + for (const button of reactionButtons) button.disabled = false; } let launcherDrag = null; @@ -586,6 +721,7 @@ setOpened(true); }); launcher.addEventListener('pointerdown', event => { + if (layout.mode !== 'detached') return; launcherDrag = { pointerId: event.pointerId, startX: event.clientX, @@ -640,11 +776,9 @@ for (const eventName of ['keydown', 'keyup', 'keypress']) { window.addEventListener(eventName, stopPageKeyboardShortcut, true); } - composer.addEventListener('submit', async event => { - event.preventDefault(); + async function sendChatText(text, clearComposerValue = false) { if (sending || !context?.enabled) return; - const submittedValue = textarea.value; - const text = textarea.value.trim(); + text = String(text || '').trim(); if (!text) return; if ([...text].length > 500) { status.textContent = strings().tooLong || ''; @@ -653,20 +787,29 @@ const generation = ++sendGeneration; sending = true; sendButton.disabled = true; + for (const button of reactionButtons) button.disabled = true; status.textContent = ''; const response = await messageRuntime({ type: 'CHAT_SEND', text }); if (destroyed || generation !== sendGeneration) return; sending = false; sendButton.disabled = false; + for (const button of reactionButtons) button.disabled = false; if (response?.status === 'ok') { - if (textarea.value === submittedValue) { + if (clearComposerValue && textarea.value.trim() === text) { textarea.value = ''; count.textContent = '0/500'; } } else { status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || ''); } + } + composer.addEventListener('submit', async event => { + event.preventDefault(); + await sendChatText(textarea.value, true); }); + for (const button of reactionButtons) { + button.addEventListener('click', () => sendChatText(button.dataset.reaction)); + } let drag = null; header.addEventListener('pointerdown', event => { @@ -738,6 +881,9 @@ setOpened(chatStartMode === 'open'); } } + if (changes.chatReactionDisplay) { + chatReactionDisplay = changes.chatReactionDisplay.newValue === 'video' ? 'video' : 'chat'; + } if (changes.locale) refresh(); } @@ -779,7 +925,7 @@ systemTheme.addEventListener('change', handleSystemTheme); chrome.storage.onChanged.addListener(handleStorage); chrome.runtime.onMessage.addListener(handleRuntime); - chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode'], data => { + chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => { const storedLayout = data[storageKey]; if (storedLayout && typeof storedLayout === 'object') { layout = { ...layout, ...storedLayout }; @@ -789,6 +935,7 @@ chatPosition = normalizePosition(data.chatPosition); chatSize = normalizeSize(data.chatSize); chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble'; + chatReactionDisplay = data.chatReactionDisplay === 'video' ? 'video' : 'chat'; layout.mode = chatPosition; if (layout.mode === 'detached') layout.detachedInitialized = true; const preset = SIZE_PRESETS[chatSize]; diff --git a/extension/locales/de.json b/extension/locales/de.json index ebc0d21..de9a16f 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Lege fest, ob der Chat als schwebende Blase oder geöffnetes Overlay startet.", "OPTION_CHAT_START_BUBBLE": "Schwebende Chatblase", "OPTION_CHAT_START_OPEN": "Geöffnetes Overlay", + "LABEL_CHAT_REACTION_DISPLAY": "Schnellreaktionen", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Lege fest, ob Schnellreaktionen nur im Chat oder zusätzlich über dem Video erscheinen.", + "OPTION_CHAT_REACTIONS_CHAT": "Nur im Chat", + "OPTION_CHAT_REACTIONS_VIDEO": "Chat und Video", + "CHAT_QUICK_REACTIONS": "Schnellreaktionen", "CHAT_TITLE": "Raum-Chat", "CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.", "CHAT_OPEN": "Raum-Chat öffnen", diff --git a/extension/locales/en.json b/extension/locales/en.json index 03f769a..7d215e9 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Choose whether chat starts as a floating bubble or an open overlay.", "OPTION_CHAT_START_BUBBLE": "Floating bubble", "OPTION_CHAT_START_OPEN": "Open overlay", + "LABEL_CHAT_REACTION_DISPLAY": "Quick reactions", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Choose whether quick reactions stay in chat or also fall over the video.", + "OPTION_CHAT_REACTIONS_CHAT": "Chat only", + "OPTION_CHAT_REACTIONS_VIDEO": "Chat and video", + "CHAT_QUICK_REACTIONS": "Quick reactions", "CHAT_TITLE": "Room Chat", "CHAT_LIVE_ONLY": "Live only. No history.", "CHAT_OPEN": "Open room chat", diff --git a/extension/locales/es.json b/extension/locales/es.json index ff4c92b..45bbbec 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Elige si el chat comienza como una burbuja flotante o una superposición abierta.", "OPTION_CHAT_START_BUBBLE": "Burbuja flotante", "OPTION_CHAT_START_OPEN": "Superposición abierta", + "LABEL_CHAT_REACTION_DISPLAY": "Reacciones rápidas", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Elige si las reacciones rápidas aparecen solo en el chat o también sobre el vídeo.", + "OPTION_CHAT_REACTIONS_CHAT": "Solo chat", + "OPTION_CHAT_REACTIONS_VIDEO": "Chat y vídeo", + "CHAT_QUICK_REACTIONS": "Reacciones rápidas", "CHAT_TITLE": "Chat de la sala", "CHAT_LIVE_ONLY": "Solo en directo. Sin historial.", "CHAT_OPEN": "Abrir chat de la sala", diff --git a/extension/locales/fr.json b/extension/locales/fr.json index 1dd3ea5..28875fa 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Choisissez si le chat démarre sous forme de bulle flottante ou de fenêtre ouverte.", "OPTION_CHAT_START_BUBBLE": "Bulle flottante", "OPTION_CHAT_START_OPEN": "Fenêtre ouverte", + "LABEL_CHAT_REACTION_DISPLAY": "Réactions rapides", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Choisissez si les réactions rapides restent dans le chat ou tombent aussi sur la vidéo.", + "OPTION_CHAT_REACTIONS_CHAT": "Chat uniquement", + "OPTION_CHAT_REACTIONS_VIDEO": "Chat et vidéo", + "CHAT_QUICK_REACTIONS": "Réactions rapides", "CHAT_TITLE": "Chat du salon", "CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.", "CHAT_OPEN": "Ouvrir le chat du salon", diff --git a/extension/locales/it.json b/extension/locales/it.json index 5311c9c..9d31954 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Scegli se la chat si avvia come bolla mobile o overlay aperto.", "OPTION_CHAT_START_BUBBLE": "Bolla fluttuante", "OPTION_CHAT_START_OPEN": "Overlay aperto", + "LABEL_CHAT_REACTION_DISPLAY": "Reazioni rapide", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Scegli se le reazioni rapide restano nella chat o cadono anche sul video.", + "OPTION_CHAT_REACTIONS_CHAT": "Solo chat", + "OPTION_CHAT_REACTIONS_VIDEO": "Chat e video", + "CHAT_QUICK_REACTIONS": "Reazioni rapide", "CHAT_TITLE": "Chat della stanza", "CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.", "CHAT_OPEN": "Apri la chat della stanza", diff --git a/extension/locales/ja.json b/extension/locales/ja.json index 879d1dc..8db139c 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "フローティングバブルまたは開いたオーバーレイで開始するか選択します。", "OPTION_CHAT_START_BUBBLE": "フローティングバブル", "OPTION_CHAT_START_OPEN": "開いたオーバーレイ", + "LABEL_CHAT_REACTION_DISPLAY": "クイックリアクション", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "クイックリアクションをチャットだけに表示するか、動画上にも降らせるかを選択します。", + "OPTION_CHAT_REACTIONS_CHAT": "チャットのみ", + "OPTION_CHAT_REACTIONS_VIDEO": "チャットと動画", + "CHAT_QUICK_REACTIONS": "クイックリアクション", "CHAT_TITLE": "ルームチャット", "CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。", "CHAT_OPEN": "ルームチャットを開く", diff --git a/extension/locales/ko.json b/extension/locales/ko.json index 36e7a28..d77a27a 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "플로팅 버블 또는 열린 오버레이로 시작할지 선택합니다.", "OPTION_CHAT_START_BUBBLE": "플로팅 채팅 버블", "OPTION_CHAT_START_OPEN": "열린 오버레이", + "LABEL_CHAT_REACTION_DISPLAY": "빠른 반응", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "빠른 반응을 채팅에만 표시하거나 동영상 위에도 떨어지게 할지 선택합니다.", + "OPTION_CHAT_REACTIONS_CHAT": "채팅만", + "OPTION_CHAT_REACTIONS_VIDEO": "채팅 및 동영상", + "CHAT_QUICK_REACTIONS": "빠른 반응", "CHAT_TITLE": "방 채팅", "CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.", "CHAT_OPEN": "방 채팅 열기", diff --git a/extension/locales/nl.json b/extension/locales/nl.json index d635430..231d014 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Kies of de chat start als zwevende knop of als geopende overlay.", "OPTION_CHAT_START_BUBBLE": "Zwevende chatknop", "OPTION_CHAT_START_OPEN": "Geopende overlay", + "LABEL_CHAT_REACTION_DISPLAY": "Snelle reacties", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Kies of snelle reacties alleen in de chat of ook over de video verschijnen.", + "OPTION_CHAT_REACTIONS_CHAT": "Alleen chat", + "OPTION_CHAT_REACTIONS_VIDEO": "Chat en video", + "CHAT_QUICK_REACTIONS": "Snelle reacties", "CHAT_TITLE": "Kamerchat", "CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.", "CHAT_OPEN": "Kamerchat openen", diff --git a/extension/locales/pl.json b/extension/locales/pl.json index e34c7ef..18ece3a 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Wybierz, czy czat ma startować jako pływający przycisk, czy otwarta nakładka.", "OPTION_CHAT_START_BUBBLE": "Pływający przycisk", "OPTION_CHAT_START_OPEN": "Otwarta nakładka", + "LABEL_CHAT_REACTION_DISPLAY": "Szybkie reakcje", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Wybierz, czy szybkie reakcje mają być tylko na czacie, czy również spadać na wideo.", + "OPTION_CHAT_REACTIONS_CHAT": "Tylko czat", + "OPTION_CHAT_REACTIONS_VIDEO": "Czat i wideo", + "CHAT_QUICK_REACTIONS": "Szybkie reakcje", "CHAT_TITLE": "Czat pokoju", "CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.", "CHAT_OPEN": "Otwórz czat pokoju", diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index e335b48..ac9dffc 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.", "OPTION_CHAT_START_BUBBLE": "Bolha flutuante", "OPTION_CHAT_START_OPEN": "Sobreposição aberta", + "LABEL_CHAT_REACTION_DISPLAY": "Reações rápidas", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Escolha se as reações rápidas ficam apenas no chat ou também caem sobre o vídeo.", + "OPTION_CHAT_REACTIONS_CHAT": "Somente chat", + "OPTION_CHAT_REACTIONS_VIDEO": "Chat e vídeo", + "CHAT_QUICK_REACTIONS": "Reações rápidas", "CHAT_TITLE": "Chat da sala", "CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.", "CHAT_OPEN": "Abrir chat da sala", diff --git a/extension/locales/pt.json b/extension/locales/pt.json index a3bfb67..7e980ef 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.", "OPTION_CHAT_START_BUBBLE": "Bolha flutuante", "OPTION_CHAT_START_OPEN": "Sobreposição aberta", + "LABEL_CHAT_REACTION_DISPLAY": "Reações rápidas", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Escolha se as reações rápidas ficam apenas no chat ou também caem sobre o vídeo.", + "OPTION_CHAT_REACTIONS_CHAT": "Apenas chat", + "OPTION_CHAT_REACTIONS_VIDEO": "Chat e vídeo", + "CHAT_QUICK_REACTIONS": "Reações rápidas", "CHAT_TITLE": "Chat da sala", "CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.", "CHAT_OPEN": "Abrir chat da sala", diff --git a/extension/locales/ru.json b/extension/locales/ru.json index 48d1bfe..0d3f607 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Выберите, запускать чат как плавающую кнопку или открытое окно.", "OPTION_CHAT_START_BUBBLE": "Плавающая кнопка", "OPTION_CHAT_START_OPEN": "Открытое окно", + "LABEL_CHAT_REACTION_DISPLAY": "Быстрые реакции", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Выберите, показывать быстрые реакции только в чате или также поверх видео.", + "OPTION_CHAT_REACTIONS_CHAT": "Только чат", + "OPTION_CHAT_REACTIONS_VIDEO": "Чат и видео", + "CHAT_QUICK_REACTIONS": "Быстрые реакции", "CHAT_TITLE": "Чат комнаты", "CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.", "CHAT_OPEN": "Открыть чат комнаты", diff --git a/extension/locales/tr.json b/extension/locales/tr.json index 5cafd13..3796f41 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Sohbetin kayan balon veya açık kaplama olarak başlamasını seçin.", "OPTION_CHAT_START_BUBBLE": "Kayan sohbet balonu", "OPTION_CHAT_START_OPEN": "Açık kaplama", + "LABEL_CHAT_REACTION_DISPLAY": "Hızlı tepkiler", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Hızlı tepkilerin yalnızca sohbette mi yoksa videonun üzerinde de mi görüneceğini seçin.", + "OPTION_CHAT_REACTIONS_CHAT": "Yalnızca sohbet", + "OPTION_CHAT_REACTIONS_VIDEO": "Sohbet ve video", + "CHAT_QUICK_REACTIONS": "Hızlı tepkiler", "CHAT_TITLE": "Oda Sohbeti", "CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.", "CHAT_OPEN": "Oda sohbetini aç", diff --git a/extension/locales/uk.json b/extension/locales/uk.json index 4d0e62b..35638b4 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "Виберіть, запускати чат як плаваючу кнопку чи відкрите вікно.", "OPTION_CHAT_START_BUBBLE": "Плаваюча кнопка", "OPTION_CHAT_START_OPEN": "Відкрите вікно", + "LABEL_CHAT_REACTION_DISPLAY": "Швидкі реакції", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Виберіть, показувати швидкі реакції лише в чаті чи також поверх відео.", + "OPTION_CHAT_REACTIONS_CHAT": "Лише чат", + "OPTION_CHAT_REACTIONS_VIDEO": "Чат і відео", + "CHAT_QUICK_REACTIONS": "Швидкі реакції", "CHAT_TITLE": "Чат кімнати", "CHAT_LIVE_ONLY": "Лише наживо. Без історії.", "CHAT_OPEN": "Відкрити чат кімнати", diff --git a/extension/locales/zh.json b/extension/locales/zh.json index 3004688..8d3f21a 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -262,6 +262,11 @@ "LABEL_CHAT_START_MODE_TOOLTIP": "选择以悬浮气泡或打开的浮层启动聊天。", "OPTION_CHAT_START_BUBBLE": "悬浮聊天气泡", "OPTION_CHAT_START_OPEN": "打开浮层", + "LABEL_CHAT_REACTION_DISPLAY": "快捷回应", + "LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "选择快捷回应仅显示在聊天中,或同时飘落在视频上。", + "OPTION_CHAT_REACTIONS_CHAT": "仅聊天", + "OPTION_CHAT_REACTIONS_VIDEO": "聊天和视频", + "CHAT_QUICK_REACTIONS": "快捷回应", "CHAT_TITLE": "房间聊天", "CHAT_LIVE_ONLY": "仅实时显示,无历史记录。", "CHAT_OPEN": "打开房间聊天", diff --git a/extension/popup.html b/extension/popup.html index afaea23..e816202 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -1669,6 +1669,15 @@ +