mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-09 18:59:48 +00:00
feat: complete docked chat interactions
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ルームチャットを開く",
|
||||
|
||||
@@ -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": "방 채팅 열기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Открыть чат комнаты",
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -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": "Відкрити чат кімнати",
|
||||
|
||||
@@ -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": "打开房间聊天",
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user