feat: complete docked chat interactions

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