mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
feat(chat): add in-page overlay
This commit is contained in:
+43
-1
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
(function exposeChatFormat(root) {
|
||||
function escapeChatHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatChatText(value) {
|
||||
return escapeChatHtml(value)
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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('<img src=x onerror=alert(1)> **bold** *italic*');
|
||||
expect(result).toBe('<img src=x onerror=alert(1)> <strong>bold</strong> <em>italic</em>');
|
||||
expect(result).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('tokenizes only text, strong, and emphasis nodes', () => {
|
||||
expect(globalThis.KoalaSyncChatFormat.tokenizeChatText('<script>x</script> **bold** *italic*')).toEqual([
|
||||
{ type: 'text', text: '<script>x</script> ' },
|
||||
{ type: 'strong', text: 'bold' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'em', text: 'italic' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(/[—–]/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "まだメッセージはありません。チャットはライブのみです。"
|
||||
}
|
||||
|
||||
@@ -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": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Сообщений пока нет. Чат работает только в реальном времени."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Повідомлень ще немає. Чат працює лише наживо."
|
||||
}
|
||||
|
||||
@@ -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": "暂无消息。聊天仅实时显示。"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user