diff --git a/extension/background.js b/extension/background.js index 0c7155c..3cc6dbb 100644 --- a/extension/background.js +++ b/extension/background.js @@ -523,6 +523,7 @@ function markRoomPotentiallyIdle() { } function clearTargetTabForIdle() { + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); currentTabId = null; currentTabTitle = null; lastContentHeartbeatAt = null; @@ -550,6 +551,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(() => {}); currentTabId = null; currentTabTitle = null; roomIdleSince = null; @@ -1049,6 +1051,7 @@ async function handleServerEvent(event, data) { hostPeerId = data.hostPeerId || null; controllers = Array.isArray(data.controllers) ? data.controllers : []; serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : []; + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); hcmEnforceDesyncInvariant(); broadcastControlMode(); markRoomPotentiallyIdle(); @@ -1829,7 +1832,7 @@ async function injectContentScript(tabId) { }); return chrome.scripting.executeScript({ target: { tabId }, - files: ['content.js'] + files: ['chat-format.js', 'chat-overlay.js', 'content.js'] }); } @@ -1899,6 +1902,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { function leaveOldRoomIfSwitching(newRoomId) { if (currentRoom && currentRoom.roomId !== newRoomId) { + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_RESET' }).catch(() => {}); addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info'); forceDisconnect(); currentRoom = null; @@ -2033,6 +2037,41 @@ async function handleAsyncMessage(message, sender, sendResponse) { chatSupported: serverSupports(CAPABILITIES.CHAT), hasChatKey: !!(await getSettings()).chatKey }); + } else if (message.type === 'GET_CHAT_CONTEXT') { + const senderTabId = sender.tab?.id; + if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) { + sendResponse({ supported: false, hasKey: false }); + return; + } + const settings = await getSettings(); + const localeData = await chrome.storage.local.get(['locale']); + await loadLocale(localeData.locale || getSystemLanguage()); + const translated = key => { + const value = getMessage(key); + return value === key ? '' : value; + }; + sendResponse({ + supported: serverSupports(CAPABILITIES.CHAT), + hasKey: !!settings.chatKey, + peerId, + roomId: currentRoom.roomId, + strings: { + title: translated('CHAT_TITLE'), + liveOnly: translated('CHAT_LIVE_ONLY'), + open: translated('CHAT_OPEN'), + close: translated('CHAT_CLOSE'), + dockLeft: translated('CHAT_DOCK_LEFT'), + dockRight: translated('CHAT_DOCK_RIGHT'), + detached: translated('CHAT_DETACHED'), + placeholder: translated('CHAT_PLACEHOLDER'), + send: translated('CHAT_SEND'), + missingKey: translated('CHAT_MISSING_KEY'), + tooLong: translated('CHAT_TOO_LONG'), + sendFailed: translated('CHAT_SEND_FAILED'), + empty: translated('CHAT_EMPTY'), + you: translated('LABEL_YOU') + } + }); } else if (message.type === 'CHAT_SEND') { const senderTabId = sender.tab?.id; if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) { @@ -2150,6 +2189,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Notify content.js/popup BEFORE currentTabId is cleared so they drop any // stale guest-side HCM state (dialog/badge/desync) — H-2/H-3. broadcastControlMode(); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); currentTabId = null; currentTabTitle = null; roomIdleSince = null; @@ -2223,6 +2263,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { broadcastConnectionStatus('connected'); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve)); for (const tab of tabs) { chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {}); @@ -2550,6 +2591,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (previousTabId && previousTabId !== currentTabId) { resetAudioProcessingInTab(previousTabId); + chrome.tabs.sendMessage(Number(previousTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); } if (currentTabId) { diff --git a/extension/chat-format.js b/extension/chat-format.js new file mode 100644 index 0000000..766eb40 --- /dev/null +++ b/extension/chat-format.js @@ -0,0 +1,33 @@ +(function exposeChatFormat(root) { + function escapeChatHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function formatChatText(value) { + return escapeChatHtml(value) + .replace(/\*\*([^*\n]+)\*\*/g, '$1') + .replace(/\*([^*\n]+)\*/g, '$1'); + } + + function tokenizeChatText(value) { + const text = String(value ?? ''); + const tokens = []; + const pattern = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g; + let cursor = 0; + let match; + while ((match = pattern.exec(text)) !== null) { + if (match.index > cursor) tokens.push({ type: 'text', text: text.slice(cursor, match.index) }); + tokens.push({ type: match[1] !== undefined ? 'strong' : 'em', text: match[1] ?? match[2] }); + cursor = pattern.lastIndex; + } + if (cursor < text.length) tokens.push({ type: 'text', text: text.slice(cursor) }); + return tokens; + } + + root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText }); +})(globalThis); diff --git a/extension/chat-format.test.mjs b/extension/chat-format.test.mjs new file mode 100644 index 0000000..ece1295 --- /dev/null +++ b/extension/chat-format.test.mjs @@ -0,0 +1,22 @@ +import { beforeAll, describe, expect, it } from 'vitest'; + +beforeAll(async () => { + await import('./chat-format.js'); +}); + +describe('chat formatting', () => { + it('escapes executable HTML before applying limited Markdown', () => { + const result = globalThis.KoalaSyncChatFormat.formatChatText(' **bold** *italic*'); + expect(result).toBe('<img src=x onerror=alert(1)> bold italic'); + expect(result).not.toContain(' { + expect(globalThis.KoalaSyncChatFormat.tokenizeChatText(' **bold** *italic*')).toEqual([ + { type: 'text', text: ' ' }, + { type: 'strong', text: 'bold' }, + { type: 'text', text: ' ' }, + { type: 'em', text: 'italic' } + ]); + }); +}); diff --git a/extension/chat-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs new file mode 100644 index 0000000..fe488f5 --- /dev/null +++ b/extension/chat-overlay-contract.test.mjs @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const extensionDir = path.dirname(new URL(import.meta.url).pathname); +const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8'); +const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8'); +const localeDir = path.join(extensionDir, 'locales'); +const chatKeys = [ + 'CHAT_TITLE', + 'CHAT_LIVE_ONLY', + 'CHAT_OPEN', + 'CHAT_CLOSE', + 'CHAT_DOCK_LEFT', + 'CHAT_DOCK_RIGHT', + 'CHAT_DETACHED', + 'CHAT_PLACEHOLDER', + 'CHAT_SEND', + 'CHAT_MISSING_KEY', + 'CHAT_TOO_LONG', + 'CHAT_SEND_FAILED', + 'CHAT_EMPTY' +]; + +describe('chat overlay contract', () => { + it('isolates the overlay and never renders markup as HTML', () => { + expect(overlaySource).toContain("attachShadow({ mode: 'open' })"); + expect(overlaySource).not.toMatch(/\.innerHTML\s*=|insertAdjacentHTML|\.outerHTML\s*=/); + expect(overlaySource).toContain('document.createTextNode(token.text)'); + }); + + it('injects formatting and overlay code only with the selected-tab content script', () => { + expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']"); + expect(overlaySource).toContain("const storageKey = `chatOverlayLayout:${location.origin}`"); + expect(overlaySource).toContain('document.fullscreenElement || document.documentElement'); + }); + + it('supports all layout and theme combinations with bounded message DOM', () => { + expect(overlaySource).toContain("['left', 'right', 'detached']"); + expect(overlaySource).toContain("#app[data-palette=\"cyber\"][data-theme=\"light\"]"); + expect(overlaySource).toContain("#app[data-palette=\"graphite\"][data-theme=\"light\"]"); + expect(overlaySource).toContain('const MAX_MESSAGES = 200'); + expect(overlaySource).toContain('while (messages.querySelectorAll(\'.message\').length > MAX_MESSAGES)'); + }); + + it('contains every chat string in all 15 extension locales', () => { + const localeFiles = fs.readdirSync(localeDir).filter(file => file.endsWith('.json')).sort(); + expect(localeFiles).toHaveLength(15); + for (const file of localeFiles) { + const messages = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8')); + for (const key of chatKeys) { + expect(messages[key], `${file}: ${key}`).toBeTypeOf('string'); + expect(messages[key], `${file}: ${key}`).not.toBe(''); + expect(messages[key], `${file}: ${key}`).not.toMatch(/[—–]/); + } + } + }); +}); diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js new file mode 100644 index 0000000..0c98950 --- /dev/null +++ b/extension/chat-overlay.js @@ -0,0 +1,467 @@ +(function initKoalaSyncChatOverlay() { + if (window.koalaSyncChatOverlay?.refresh) { + window.koalaSyncChatOverlay.refresh(); + return; + } + + const MAX_MESSAGES = 200; + const MIN_WIDTH = 300; + const MIN_HEIGHT = 320; + const DEFAULT_WIDTH = 360; + const DEFAULT_HEIGHT = 520; + const storageKey = `chatOverlayLayout:${location.origin}`; + const systemTheme = window.matchMedia('(prefers-color-scheme: light)'); + let context = null; + let opened = false; + let destroyed = false; + let saveTimer = null; + let applyingLayout = false; + let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }; + let themeMode = 'system'; + let themePalette = 'eucalyptus'; + + const host = document.createElement('div'); + host.id = 'koalasync-chat-overlay-host'; + host.style.cssText = 'all:initial;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);'; + const shadow = host.attachShadow({ mode: 'open' }); + const style = document.createElement('style'); + style.textContent = ` + :host { all: initial; } + * { box-sizing: border-box; } + #app { + --bg: oklch(0.205 0.025 155); --card: oklch(0.30 0.035 150); + --surface-alt: oklch(0.265 0.032 150); --surface-deep: oklch(0.205 0.025 155); + --accent: oklch(0.73 0.13 155); --accent-hover: oklch(0.79 0.12 155); + --text: oklch(0.96 0.01 90); --text-muted: oklch(0.78 0.02 90); + --border-soft: oklch(0.88 0.025 145 / 0.09); --border-strong: oklch(0.39 0.038 145); + --text-on-green: oklch(0.14 0.02 90); --danger: oklch(0.63 0.18 25); + position: fixed; inset: 0; pointer-events: none; color: var(--text); + font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + #app[data-theme="light"] { + --bg: oklch(0.935 0.018 110); --card: oklch(0.995 0.006 100); + --surface-alt: oklch(0.91 0.022 115); --surface-deep: oklch(0.935 0.018 110); + --accent: oklch(0.52 0.13 155); --accent-hover: oklch(0.45 0.12 155); + --text: oklch(0.21 0.03 140); --text-muted: oklch(0.43 0.03 135); + --border-soft: oklch(0.28 0.035 140 / 0.10); --border-strong: oklch(0.73 0.035 125); + --text-on-green: oklch(0.16 0.03 140); + } + #app[data-palette="cyber"] { + --bg: oklch(0.185 0.04 265); --card: oklch(0.285 0.05 265); + --surface-alt: oklch(0.25 0.048 265); --surface-deep: oklch(0.185 0.04 265); + --accent: oklch(0.59 0.20 275); --accent-hover: oklch(0.69 0.17 275); + --text: oklch(0.97 0.012 265); --text-muted: oklch(0.74 0.03 265); + --border-soft: oklch(0.9 0.03 265 / 0.10); --border-strong: oklch(0.42 0.05 265); + --text-on-green: oklch(0.99 0.006 275); + } + #app[data-palette="cyber"][data-theme="light"] { + --bg: oklch(0.945 0.014 265); --card: oklch(0.995 0.005 265); + --surface-alt: oklch(0.915 0.02 265); --surface-deep: oklch(0.945 0.014 265); + --accent: oklch(0.53 0.21 275); --accent-hover: oklch(0.46 0.20 275); + --text: oklch(0.26 0.05 265); --text-muted: oklch(0.48 0.045 265); + --border-soft: oklch(0.30 0.05 265 / 0.12); --border-strong: oklch(0.79 0.03 265); + --text-on-green: oklch(0.99 0.005 275); + } + #app[data-palette="graphite"] { + --bg: oklch(0.165 0.004 260); --card: oklch(0.255 0.005 260); + --surface-alt: oklch(0.225 0.005 260); --surface-deep: oklch(0.165 0.004 260); + --accent: oklch(0.93 0.006 260); --accent-hover: oklch(0.99 0.003 260); + --text: oklch(0.97 0.003 260); --text-muted: oklch(0.72 0.005 260); + --border-soft: oklch(0.92 0.008 260 / 0.11); --border-strong: oklch(0.42 0.006 260); + --text-on-green: oklch(0.20 0.004 260); + } + #app[data-palette="graphite"][data-theme="light"] { + --bg: oklch(0.928 0.003 260); --card: oklch(1 0 0); + --surface-alt: oklch(0.90 0.004 260); --surface-deep: oklch(0.928 0.003 260); + --accent: oklch(0.28 0.006 260); --accent-hover: oklch(0.18 0.005 260); + --text: oklch(0.22 0.005 260); --text-muted: oklch(0.46 0.006 260); + --border-soft: oklch(0.20 0.006 260 / 0.13); --border-strong: oklch(0.77 0.004 260); + --text-on-green: oklch(0.98 0.002 260); + } + 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(:disabled) { border-color: var(--accent); transform: translateY(-1px); } + .launcher:disabled { cursor: not-allowed; opacity: .58; } + .panel { + position: fixed; display: none; flex-direction: column; overflow: hidden; + min-width: ${MIN_WIDTH}px; min-height: ${MIN_HEIGHT}px; 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; } + .header.detached { cursor: move; } + .heading { min-width: 0; flex: 1; } + .title { font-size: 14px; font-weight: 760; } + .subtitle { color: var(--text-muted); font-size: 11px; } + .modes { display: flex; gap: 4px; } + .icon-button { width: 30px; height: 30px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--surface-alt); cursor: pointer; } + .icon-button:hover, .icon-button.active { border-color: var(--accent); color: var(--accent); } + .messages { flex: 1; min-height: 0; overflow-y: auto; padding: 12px; background: var(--bg); overscroll-behavior: contain; } + .empty { color: var(--text-muted); text-align: center; padding: 32px 12px; font-size: 12px; } + .message { margin-bottom: 11px; overflow-wrap: anywhere; } + .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); } + .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); } + textarea::placeholder { color: var(--text-muted); } + .composer-row { display: flex; align-items: center; gap: 8px; margin-top: 7px; } + .count { flex: 1; color: var(--text-muted); font-size: 10px; } + .status { color: var(--danger); font-size: 10px; min-height: 14px; } + .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; } + @media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } } + @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } + `; + + function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + const app = element('div'); + app.id = 'app'; + const launcher = element('button', 'launcher', '💬'); + launcher.type = 'button'; + const panel = element('section', 'panel'); + panel.setAttribute('role', 'dialog'); + const header = element('header', 'header'); + const heading = element('div', 'heading'); + const title = element('div', 'title'); + const subtitle = element('div', 'subtitle'); + heading.append(title, subtitle); + const modes = element('div', 'modes'); + const leftButton = 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'; + modes.append(leftButton, detachedButton, rightButton, closeButton); + header.append(heading, modes); + const messages = element('div', 'messages'); + messages.setAttribute('aria-live', 'polite'); + const empty = element('div', 'empty'); + messages.append(empty); + const composer = element('form', 'composer'); + const textarea = element('textarea'); + const composerRow = element('div', 'composer-row'); + const count = element('span', 'count', '0/500'); + const sendButton = element('button', 'send'); + sendButton.type = 'submit'; + composerRow.append(count, sendButton); + const status = element('div', 'status'); + composer.append(textarea, composerRow, status); + panel.append(header, messages, composer); + app.append(launcher, panel); + shadow.append(style, app); + document.documentElement.append(host); + + function messageRuntime(payload) { + return new Promise(resolve => { + try { + chrome.runtime.sendMessage(payload, response => { + if (chrome.runtime.lastError) resolve(null); + else resolve(response || null); + }); + } catch (_) { + resolve(null); + } + }); + } + + function strings() { + return context?.strings || {}; + } + + function applyStrings() { + const text = strings(); + title.textContent = text.title || ''; + subtitle.textContent = text.liveOnly || ''; + launcher.setAttribute('aria-label', text.open || ''); + launcher.title = context?.supported && !context?.hasKey ? (text.missingKey || '') : (text.open || ''); + panel.setAttribute('aria-label', text.title || ''); + textarea.placeholder = text.placeholder || ''; + sendButton.textContent = text.send || ''; + empty.textContent = text.empty || ''; + leftButton.title = text.dockLeft || ''; + detachedButton.title = text.detached || ''; + rightButton.title = text.dockRight || ''; + closeButton.title = text.close || ''; + leftButton.setAttribute('aria-label', text.dockLeft || ''); + detachedButton.setAttribute('aria-label', text.detached || ''); + rightButton.setAttribute('aria-label', text.dockRight || ''); + closeButton.setAttribute('aria-label', text.close || ''); + } + + function applyTheme() { + const light = themeMode === 'light' || (themeMode === 'system' && systemTheme.matches); + app.dataset.theme = light ? 'light' : 'dark'; + app.dataset.palette = ['eucalyptus', 'cyber', 'graphite'].includes(themePalette) ? themePalette : 'eucalyptus'; + } + + function viewportBounds() { + return { width: Math.max(320, window.innerWidth), height: Math.max(360, window.innerHeight) }; + } + + function clampDetached() { + const viewport = viewportBounds(); + layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, MIN_WIDTH), Math.min(600, viewport.width - 16)); + layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, MIN_HEIGHT), viewport.height - 16); + layout.x = Math.min(Math.max(Number(layout.x) || 8, 8), Math.max(8, viewport.width - layout.width - 8)); + layout.y = Math.min(Math.max(Number(layout.y) || 8, 8), Math.max(8, viewport.height - layout.height - 8)); + } + + function saveLayout() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + saveTimer = null; + chrome.storage.local.set({ [storageKey]: layout }).catch(() => {}); + }, 150); + } + + function applyLayout() { + applyingLayout = true; + panel.style.right = 'auto'; + panel.style.left = 'auto'; + panel.style.bottom = 'auto'; + panel.style.resize = 'none'; + 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') { + 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 { + panel.style.top = '64px'; + panel.style.width = `${DEFAULT_WIDTH}px`; + panel.style.height = 'min(560px, calc(100vh - 80px))'; + panel.style[layout.mode] = '16px'; + launcher.style.top = 'calc(50vh - 24px)'; + launcher.style.left = layout.mode === 'left' ? '16px' : 'auto'; + launcher.style.right = layout.mode === 'right' ? '16px' : 'auto'; + } + globalThis.queueMicrotask(() => { applyingLayout = false; }); + } + + function setMode(mode) { + if (!['left', 'right', 'detached'].includes(mode)) return; + if (mode === 'detached' && layout.mode !== 'detached') { + layout.x = Math.max(8, Math.round((window.innerWidth - DEFAULT_WIDTH) / 2)); + layout.y = Math.max(8, Math.round((window.innerHeight - DEFAULT_HEIGHT) / 2)); + } + layout.mode = mode; + applyLayout(); + saveLayout(); + } + + function setOpened(next) { + opened = !!next && !!context?.enabled; + panel.classList.toggle('open', opened); + launcher.style.display = opened ? 'none' : ''; + if (opened) { + textarea.focus(); + messages.scrollTop = messages.scrollHeight; + } + } + + function applyContext(next) { + const previousRoomId = context?.roomId; + context = next || null; + const supported = !!context?.supported; + const hasKey = !!context?.hasKey; + context = context ? { ...context, enabled: supported && hasKey } : null; + if (previousRoomId && previousRoomId !== context?.roomId) clearMessages(); + host.style.display = supported ? '' : 'none'; + launcher.disabled = !hasKey; + if (!supported || !hasKey) setOpened(false); + applyStrings(); + applyLayout(); + } + + async function refresh() { + if (destroyed) return; + applyContext(await messageRuntime({ type: 'GET_CHAT_CONTEXT' })); + } + + function appendMessage(message) { + if (!context?.enabled || !message || typeof message.text !== 'string') return; + if (empty.isConnected) empty.remove(); + const wrapper = element('article', 'message'); + const meta = element('div', 'meta'); + const own = message.senderId === context.peerId; + const sender = element('span', 'sender', own ? (strings().you || '') : (message.username || message.senderId || '')); + const time = element('time', 'time'); + const date = new Date(message.timestamp); + time.textContent = Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; + meta.append(sender, time); + const body = element('div', 'body'); + const tokens = globalThis.KoalaSyncChatFormat?.tokenizeChatText(message.text) || [{ type: 'text', text: message.text }]; + for (const token of tokens) { + const node = token.type === 'strong' ? document.createElement('strong') : token.type === 'em' ? document.createElement('em') : document.createTextNode(token.text); + if (node.nodeType === 1) node.textContent = token.text; + body.append(node); + } + wrapper.append(meta, body); + messages.append(wrapper); + while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove(); + messages.scrollTop = messages.scrollHeight; + } + + function clearMessages() { + messages.replaceChildren(empty); + } + + launcher.addEventListener('click', () => setOpened(true)); + closeButton.addEventListener('click', () => setOpened(false)); + leftButton.addEventListener('click', () => setMode('left')); + detachedButton.addEventListener('click', () => setMode('detached')); + rightButton.addEventListener('click', () => setMode('right')); + + textarea.addEventListener('input', () => { + const length = [...textarea.value].length; + count.textContent = `${length}/500`; + count.style.color = length > 500 ? 'var(--danger)' : ''; + status.textContent = length > 500 ? (strings().tooLong || '') : ''; + }); + textarea.addEventListener('keydown', event => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + composer.requestSubmit(); + } + }); + composer.addEventListener('submit', async event => { + event.preventDefault(); + const text = textarea.value.trim(); + if (!text) return; + if ([...text].length > 500) { + status.textContent = strings().tooLong || ''; + return; + } + sendButton.disabled = true; + status.textContent = ''; + const response = await messageRuntime({ type: 'CHAT_SEND', text }); + sendButton.disabled = false; + if (response?.status === 'ok') { + textarea.value = ''; + count.textContent = '0/500'; + } else { + status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || ''); + } + }); + + let drag = null; + header.addEventListener('pointerdown', event => { + if (layout.mode !== 'detached' || event.target.closest('button')) return; + drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: layout.x, y: layout.y }; + header.setPointerCapture(event.pointerId); + }); + header.addEventListener('pointermove', event => { + if (!drag || event.pointerId !== drag.pointerId) return; + layout.x = drag.x + event.clientX - drag.startX; + layout.y = drag.y + event.clientY - drag.startY; + clampDetached(); + applyLayout(); + }); + header.addEventListener('pointerup', event => { + if (!drag || event.pointerId !== drag.pointerId) return; + drag = null; + saveLayout(); + }); + + const resizeObserver = new globalThis.ResizeObserver(entries => { + if (applyingLayout || layout.mode !== 'detached') return; + const rect = entries[0]?.contentRect; + if (!opened || !rect?.width || !rect.height) return; + layout.width = rect.width; + layout.height = rect.height; + clampDetached(); + saveLayout(); + }); + resizeObserver.observe(panel); + + function moveIntoFullscreen() { + const parent = document.fullscreenElement || document.documentElement; + if (parent && host.parentNode !== parent) parent.append(host); + globalThis.requestAnimationFrame(applyLayout); + } + + function handleResize() { + if (layout.mode === 'detached') { + clampDetached(); + applyLayout(); + saveLayout(); + } + } + + function handleSystemTheme() { + if (themeMode === 'system') applyTheme(); + } + + function handleStorage(changes, area) { + if (area !== 'local') return; + if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system'; + if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus'; + if (changes.themeMode || changes.themePalette) applyTheme(); + if (changes.locale) refresh(); + } + + function handleRuntime(message) { + if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message); + if (message?.type === 'CHAT_CONTEXT_UPDATE') refresh(); + if (message?.type === 'CHAT_RESET') { + clearMessages(); + refresh(); + } + if (message?.type === 'CHAT_DESTROY') destroy(); + } + + function destroy() { + if (destroyed) return; + destroyed = true; + if (saveTimer) clearTimeout(saveTimer); + resizeObserver.disconnect(); + document.removeEventListener('fullscreenchange', moveIntoFullscreen); + window.removeEventListener('resize', handleResize); + systemTheme.removeEventListener('change', handleSystemTheme); + chrome.storage.onChanged.removeListener(handleStorage); + chrome.runtime.onMessage.removeListener(handleRuntime); + host.remove(); + window.koalaSyncChatOverlay = null; + } + + document.addEventListener('fullscreenchange', moveIntoFullscreen); + window.addEventListener('resize', handleResize, { passive: true }); + systemTheme.addEventListener('change', handleSystemTheme); + chrome.storage.onChanged.addListener(handleStorage); + chrome.runtime.onMessage.addListener(handleRuntime); + chrome.storage.local.get([storageKey, 'themeMode', 'themePalette'], data => { + if (data[storageKey] && typeof data[storageKey] === 'object') layout = { ...layout, ...data[storageKey] }; + themeMode = data.themeMode || 'system'; + themePalette = data.themePalette || 'eucalyptus'; + applyTheme(); + applyLayout(); + }); + + window.koalaSyncChatOverlay = { refresh, destroy }; + refresh(); +})(); diff --git a/extension/locales/de.json b/extension/locales/de.json index 6507be7..8f258da 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profil & Aussehen", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.", "LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an.", + "CHAT_TITLE": "Raum-Chat", + "CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.", + "CHAT_OPEN": "Raum-Chat öffnen", + "CHAT_CLOSE": "Chat schließen", + "CHAT_DOCK_LEFT": "Chat links andocken", + "CHAT_DOCK_RIGHT": "Chat rechts andocken", + "CHAT_DETACHED": "Chat frei positionieren", + "CHAT_PLACEHOLDER": "Nachricht schreiben...", + "CHAT_SEND": "Senden", + "CHAT_MISSING_KEY": "Chat ist nur über einen aktuellen Einladungslink verfügbar.", + "CHAT_TOO_LONG": "Maximal 500 Zeichen.", + "CHAT_SEND_FAILED": "Nachricht konnte nicht gesendet werden.", + "CHAT_EMPTY": "Noch keine Nachrichten. Der Chat ist nur live." } diff --git a/extension/locales/en.json b/extension/locales/en.json index 25f5c04..0fb0ab3 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profile & Appearance", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Change your username, theme, and language preferences.", "LABEL_SETTINGS_GROUP_SYNC": "Playback & Sync", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences.", + "CHAT_TITLE": "Room Chat", + "CHAT_LIVE_ONLY": "Live only. No history.", + "CHAT_OPEN": "Open room chat", + "CHAT_CLOSE": "Close chat", + "CHAT_DOCK_LEFT": "Dock chat left", + "CHAT_DOCK_RIGHT": "Dock chat right", + "CHAT_DETACHED": "Detach chat", + "CHAT_PLACEHOLDER": "Write a message...", + "CHAT_SEND": "Send", + "CHAT_MISSING_KEY": "Chat is available only through a current invite link.", + "CHAT_TOO_LONG": "Maximum 500 characters.", + "CHAT_SEND_FAILED": "Message could not be sent.", + "CHAT_EMPTY": "No messages yet. Chat is live only." } diff --git a/extension/locales/es.json b/extension/locales/es.json index 624e419..c9c9da9 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Perfil y Apariencia", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Cambia tu nombre de usuario, tema y preferencias de idioma.", "LABEL_SETTINGS_GROUP_SYNC": "Reproducción y Sincronización", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio.", + "CHAT_TITLE": "Chat de la sala", + "CHAT_LIVE_ONLY": "Solo en directo. Sin historial.", + "CHAT_OPEN": "Abrir chat de la sala", + "CHAT_CLOSE": "Cerrar chat", + "CHAT_DOCK_LEFT": "Acoplar chat a la izquierda", + "CHAT_DOCK_RIGHT": "Acoplar chat a la derecha", + "CHAT_DETACHED": "Separar chat", + "CHAT_PLACEHOLDER": "Escribe un mensaje...", + "CHAT_SEND": "Enviar", + "CHAT_MISSING_KEY": "El chat solo está disponible mediante un enlace de invitación actual.", + "CHAT_TOO_LONG": "Máximo 500 caracteres.", + "CHAT_SEND_FAILED": "No se pudo enviar el mensaje.", + "CHAT_EMPTY": "Aún no hay mensajes. El chat solo es en directo." } diff --git a/extension/locales/fr.json b/extension/locales/fr.json index 67e0408..ea6ee69 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profil & Apparence", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifiez votre nom d'utilisateur, votre thème et vos préférences linguistiques.", "LABEL_SETTINGS_GROUP_SYNC": "Lecture & Sync", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio.", + "CHAT_TITLE": "Chat du salon", + "CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.", + "CHAT_OPEN": "Ouvrir le chat du salon", + "CHAT_CLOSE": "Fermer le chat", + "CHAT_DOCK_LEFT": "Ancrer le chat à gauche", + "CHAT_DOCK_RIGHT": "Ancrer le chat à droite", + "CHAT_DETACHED": "Détacher le chat", + "CHAT_PLACEHOLDER": "Écrire un message...", + "CHAT_SEND": "Envoyer", + "CHAT_MISSING_KEY": "Le chat est disponible uniquement via un lien d'invitation actuel.", + "CHAT_TOO_LONG": "500 caractères maximum.", + "CHAT_SEND_FAILED": "Le message n'a pas pu être envoyé.", + "CHAT_EMPTY": "Aucun message. Le chat est uniquement en direct." } diff --git a/extension/locales/it.json b/extension/locales/it.json index bb72c95..fec5034 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profilo & Aspetto", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifica il tuo nome utente, tema e preferenze di lingua.", "LABEL_SETTINGS_GROUP_SYNC": "Riproduzione & Sync", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio.", + "CHAT_TITLE": "Chat della stanza", + "CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.", + "CHAT_OPEN": "Apri la chat della stanza", + "CHAT_CLOSE": "Chiudi chat", + "CHAT_DOCK_LEFT": "Aggancia chat a sinistra", + "CHAT_DOCK_RIGHT": "Aggancia chat a destra", + "CHAT_DETACHED": "Sgancia chat", + "CHAT_PLACEHOLDER": "Scrivi un messaggio...", + "CHAT_SEND": "Invia", + "CHAT_MISSING_KEY": "La chat è disponibile solo tramite un link di invito attuale.", + "CHAT_TOO_LONG": "Massimo 500 caratteri.", + "CHAT_SEND_FAILED": "Impossibile inviare il messaggio.", + "CHAT_EMPTY": "Nessun messaggio. La chat è solo in diretta." } diff --git a/extension/locales/ja.json b/extension/locales/ja.json index 70162a8..5c04171 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "プロフィールと外観", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。", "LABEL_SETTINGS_GROUP_SYNC": "再生と同期", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。" + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。", + "CHAT_TITLE": "ルームチャット", + "CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。", + "CHAT_OPEN": "ルームチャットを開く", + "CHAT_CLOSE": "チャットを閉じる", + "CHAT_DOCK_LEFT": "チャットを左に固定", + "CHAT_DOCK_RIGHT": "チャットを右に固定", + "CHAT_DETACHED": "チャットを切り離す", + "CHAT_PLACEHOLDER": "メッセージを入力...", + "CHAT_SEND": "送信", + "CHAT_MISSING_KEY": "チャットは最新の招待リンクからのみ利用できます。", + "CHAT_TOO_LONG": "最大500文字です。", + "CHAT_SEND_FAILED": "メッセージを送信できませんでした。", + "CHAT_EMPTY": "まだメッセージはありません。チャットはライブのみです。" } diff --git a/extension/locales/ko.json b/extension/locales/ko.json index d40a320..25721a2 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "프로필 및 테마", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.", "LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.", + "CHAT_TITLE": "방 채팅", + "CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.", + "CHAT_OPEN": "방 채팅 열기", + "CHAT_CLOSE": "채팅 닫기", + "CHAT_DOCK_LEFT": "채팅을 왼쪽에 고정", + "CHAT_DOCK_RIGHT": "채팅을 오른쪽에 고정", + "CHAT_DETACHED": "채팅 분리", + "CHAT_PLACEHOLDER": "메시지를 입력하세요...", + "CHAT_SEND": "보내기", + "CHAT_MISSING_KEY": "채팅은 최신 초대 링크를 통해서만 사용할 수 있습니다.", + "CHAT_TOO_LONG": "최대 500자입니다.", + "CHAT_SEND_FAILED": "메시지를 보낼 수 없습니다.", + "CHAT_EMPTY": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다." } diff --git a/extension/locales/nl.json b/extension/locales/nl.json index dd2a201..ef78f36 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profiel & Weergave", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.", "LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan.", + "CHAT_TITLE": "Kamerchat", + "CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.", + "CHAT_OPEN": "Kamerchat openen", + "CHAT_CLOSE": "Chat sluiten", + "CHAT_DOCK_LEFT": "Chat links vastzetten", + "CHAT_DOCK_RIGHT": "Chat rechts vastzetten", + "CHAT_DETACHED": "Chat losmaken", + "CHAT_PLACEHOLDER": "Schrijf een bericht...", + "CHAT_SEND": "Verzenden", + "CHAT_MISSING_KEY": "Chat is alleen beschikbaar via een actuele uitnodigingslink.", + "CHAT_TOO_LONG": "Maximaal 500 tekens.", + "CHAT_SEND_FAILED": "Bericht kon niet worden verzonden.", + "CHAT_EMPTY": "Nog geen berichten. Chat is alleen live." } diff --git a/extension/locales/pl.json b/extension/locales/pl.json index dde3e3d..2d6964d 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profil i Wygląd", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Zmień swoją nazwę użytkownika, motyw i preferencje językowe.", "LABEL_SETTINGS_GROUP_SYNC": "Odtwarzanie i Sync", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio.", + "CHAT_TITLE": "Czat pokoju", + "CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.", + "CHAT_OPEN": "Otwórz czat pokoju", + "CHAT_CLOSE": "Zamknij czat", + "CHAT_DOCK_LEFT": "Przypnij czat po lewej", + "CHAT_DOCK_RIGHT": "Przypnij czat po prawej", + "CHAT_DETACHED": "Odłącz czat", + "CHAT_PLACEHOLDER": "Napisz wiadomość...", + "CHAT_SEND": "Wyślij", + "CHAT_MISSING_KEY": "Czat jest dostępny tylko przez aktualny link zaproszenia.", + "CHAT_TOO_LONG": "Maksymalnie 500 znaków.", + "CHAT_SEND_FAILED": "Nie udało się wysłać wiadomości.", + "CHAT_EMPTY": "Brak wiadomości. Czat działa tylko na żywo." } diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index 1d07782..9d75de4 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Perfil e Aparência", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de usuário, tema e preferências de idioma.", "LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.", + "CHAT_TITLE": "Chat da sala", + "CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.", + "CHAT_OPEN": "Abrir chat da sala", + "CHAT_CLOSE": "Fechar chat", + "CHAT_DOCK_LEFT": "Fixar chat à esquerda", + "CHAT_DOCK_RIGHT": "Fixar chat à direita", + "CHAT_DETACHED": "Desanexar chat", + "CHAT_PLACEHOLDER": "Escreva uma mensagem...", + "CHAT_SEND": "Enviar", + "CHAT_MISSING_KEY": "O chat está disponível apenas por um link de convite atual.", + "CHAT_TOO_LONG": "Máximo de 500 caracteres.", + "CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.", + "CHAT_EMPTY": "Ainda não há mensagens. O chat é somente ao vivo." } diff --git a/extension/locales/pt.json b/extension/locales/pt.json index b360401..3bc3b91 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Perfil e Aparência", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de utilizador, tema e preferências de idioma.", "LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.", + "CHAT_TITLE": "Chat da sala", + "CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.", + "CHAT_OPEN": "Abrir chat da sala", + "CHAT_CLOSE": "Fechar chat", + "CHAT_DOCK_LEFT": "Fixar chat à esquerda", + "CHAT_DOCK_RIGHT": "Fixar chat à direita", + "CHAT_DETACHED": "Desanexar chat", + "CHAT_PLACEHOLDER": "Escreva uma mensagem...", + "CHAT_SEND": "Enviar", + "CHAT_MISSING_KEY": "O chat está disponível apenas através de um link de convite atual.", + "CHAT_TOO_LONG": "Máximo de 500 caracteres.", + "CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.", + "CHAT_EMPTY": "Ainda não há mensagens. O chat funciona apenas em direto." } diff --git a/extension/locales/ru.json b/extension/locales/ru.json index 7aa0e35..b43dca0 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Профиль и Оформление", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.", "LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.", + "CHAT_TITLE": "Чат комнаты", + "CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.", + "CHAT_OPEN": "Открыть чат комнаты", + "CHAT_CLOSE": "Закрыть чат", + "CHAT_DOCK_LEFT": "Закрепить чат слева", + "CHAT_DOCK_RIGHT": "Закрепить чат справа", + "CHAT_DETACHED": "Открепить чат", + "CHAT_PLACEHOLDER": "Напишите сообщение...", + "CHAT_SEND": "Отправить", + "CHAT_MISSING_KEY": "Чат доступен только по актуальной ссылке-приглашению.", + "CHAT_TOO_LONG": "Не более 500 символов.", + "CHAT_SEND_FAILED": "Не удалось отправить сообщение.", + "CHAT_EMPTY": "Сообщений пока нет. Чат работает только в реальном времени." } diff --git a/extension/locales/tr.json b/extension/locales/tr.json index b5b29da..f00a1b6 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profil ve Görünüm", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Kullanıcı adınızı, temanızı ve dil tercihlerinizi değiştirin.", "LABEL_SETTINGS_GROUP_SYNC": "Oynatma ve Eşitleme", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin.", + "CHAT_TITLE": "Oda Sohbeti", + "CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.", + "CHAT_OPEN": "Oda sohbetini aç", + "CHAT_CLOSE": "Sohbeti kapat", + "CHAT_DOCK_LEFT": "Sohbeti sola sabitle", + "CHAT_DOCK_RIGHT": "Sohbeti sağa sabitle", + "CHAT_DETACHED": "Sohbeti ayır", + "CHAT_PLACEHOLDER": "Bir mesaj yazın...", + "CHAT_SEND": "Gönder", + "CHAT_MISSING_KEY": "Sohbet yalnızca güncel bir davet bağlantısıyla kullanılabilir.", + "CHAT_TOO_LONG": "En fazla 500 karakter.", + "CHAT_SEND_FAILED": "Mesaj gönderilemedi.", + "CHAT_EMPTY": "Henüz mesaj yok. Sohbet yalnızca canlıdır." } diff --git a/extension/locales/uk.json b/extension/locales/uk.json index 22aa7d5..13dbbdb 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Профіль та Вигляд", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.", "LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.", + "CHAT_TITLE": "Чат кімнати", + "CHAT_LIVE_ONLY": "Лише наживо. Без історії.", + "CHAT_OPEN": "Відкрити чат кімнати", + "CHAT_CLOSE": "Закрити чат", + "CHAT_DOCK_LEFT": "Закріпити чат ліворуч", + "CHAT_DOCK_RIGHT": "Закріпити чат праворуч", + "CHAT_DETACHED": "Від'єднати чат", + "CHAT_PLACEHOLDER": "Напишіть повідомлення...", + "CHAT_SEND": "Надіслати", + "CHAT_MISSING_KEY": "Чат доступний лише за актуальним посиланням-запрошенням.", + "CHAT_TOO_LONG": "Не більше 500 символів.", + "CHAT_SEND_FAILED": "Не вдалося надіслати повідомлення.", + "CHAT_EMPTY": "Повідомлень ще немає. Чат працює лише наживо." } diff --git a/extension/locales/zh.json b/extension/locales/zh.json index dda1b53..311bb0d 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "个人资料与外观", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。", "LABEL_SETTINGS_GROUP_SYNC": "播放与同步", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。" + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。", + "CHAT_TITLE": "房间聊天", + "CHAT_LIVE_ONLY": "仅实时显示,无历史记录。", + "CHAT_OPEN": "打开房间聊天", + "CHAT_CLOSE": "关闭聊天", + "CHAT_DOCK_LEFT": "将聊天固定在左侧", + "CHAT_DOCK_RIGHT": "将聊天固定在右侧", + "CHAT_DETACHED": "浮动聊天", + "CHAT_PLACEHOLDER": "输入消息...", + "CHAT_SEND": "发送", + "CHAT_MISSING_KEY": "聊天仅可通过当前邀请链接使用。", + "CHAT_TOO_LONG": "最多500个字符。", + "CHAT_SEND_FAILED": "无法发送消息。", + "CHAT_EMPTY": "暂无消息。聊天仅实时显示。" }