mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
fix(chat): harden overlay interactions and notifications
This commit is contained in:
+76
-6
@@ -398,7 +398,7 @@ async function getSettings() {
|
||||
// (username) must NEVER come from storage.sync — syncing them across devices
|
||||
// both leaks them and resurrects dead rooms on reinstall (a fresh install
|
||||
// has empty local storage but sync survives in the user's Google account).
|
||||
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
|
||||
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
username = generateUsername();
|
||||
@@ -417,6 +417,7 @@ async function getSettings() {
|
||||
password: data.password || '',
|
||||
chatKey,
|
||||
chatEnabled: data.chatEnabled === true,
|
||||
chatNotifications: data.chatNotifications !== false,
|
||||
username,
|
||||
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
|
||||
mediaTitlePrivacyMode
|
||||
@@ -453,7 +454,7 @@ function emitEpisodeLobbyForCurrentPrivacy() {
|
||||
// otherwise be redistributed across devices and resurrected on reinstall).
|
||||
const LEGACY_SYNC_KEYS = [
|
||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey',
|
||||
'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username',
|
||||
'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'username',
|
||||
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
||||
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
|
||||
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
|
||||
@@ -907,7 +908,11 @@ function showNotification(senderName, action) {
|
||||
displayName = getMessage('LABEL_YOU') || 'YOU';
|
||||
}
|
||||
|
||||
const message = getMessage('TOAST_PEER_ACTION', { name: displayName, action: label }) + '.';
|
||||
const message = action === 'joined'
|
||||
? getMessage('TOAST_PEER_JOINED', { name: displayName })
|
||||
: action === 'left'
|
||||
? getMessage('TOAST_PEER_LEFT', { name: displayName })
|
||||
: getMessage('TOAST_PEER_ACTION', { name: displayName, action: label }) + '.';
|
||||
|
||||
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
@@ -919,6 +924,43 @@ function showNotification(senderName, action) {
|
||||
});
|
||||
}
|
||||
|
||||
function showChatNotification(displayName, text) {
|
||||
chrome.storage.local.get(['chatNotifications', 'locale'], async (settings) => {
|
||||
if (settings.chatNotifications === false) return;
|
||||
|
||||
await loadLocale(settings.locale || getSystemLanguage());
|
||||
chrome.notifications.create(`chat_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: getMessage('CHAT_TITLE') || 'KoalaSync',
|
||||
message: `${displayName || getMessage('CHAT_TITLE') || 'Room Chat'}: ${text}`,
|
||||
priority: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function chatActivityDisplayName(senderId) {
|
||||
if (senderId === peerId) return '';
|
||||
const peer = currentRoom?.peers?.find(candidate =>
|
||||
(typeof candidate === 'object' ? candidate.peerId : candidate) === senderId
|
||||
);
|
||||
return typeof peer === 'object' ? peer.username || senderId : senderId;
|
||||
}
|
||||
|
||||
function sendChatActivity(action, senderId, timestamp = Date.now()) {
|
||||
if (!currentTabId || !serverSupportsChat()) return;
|
||||
if (![EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE, 'joined', 'left'].includes(action)) return;
|
||||
chrome.tabs.sendMessage(Number(currentTabId), {
|
||||
type: 'CHAT_EVENT',
|
||||
event: {
|
||||
action,
|
||||
senderId,
|
||||
username: chatActivityDisplayName(senderId),
|
||||
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now()
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
|
||||
@@ -1235,6 +1277,12 @@ async function handleServerEvent(event, data) {
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (received.senderId !== peerId) {
|
||||
showChatNotification(
|
||||
typeof senderPeer === 'object' ? senderPeer.username : received.senderId,
|
||||
text
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn');
|
||||
}
|
||||
@@ -1291,6 +1339,7 @@ async function handleServerEvent(event, data) {
|
||||
if (data.senderId) {
|
||||
addToHistory(event, data.senderId);
|
||||
showNotification(data.senderId, event);
|
||||
sendChatActivity(event, data.senderId, data.actionTimestamp);
|
||||
updateLastAction(event, data.senderId);
|
||||
lastActionState.targetTime = data.targetTime !== undefined ? data.targetTime : data.currentTime;
|
||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||
@@ -1348,6 +1397,7 @@ async function handleServerEvent(event, data) {
|
||||
if (data?.senderId) {
|
||||
addToHistory(event, data.senderId);
|
||||
showNotification(data.senderId, event);
|
||||
sendChatActivity(event, data.senderId, data.actionTimestamp);
|
||||
|
||||
// (The sender's state is updated below with everyone else)
|
||||
}
|
||||
@@ -1403,6 +1453,8 @@ async function handleServerEvent(event, data) {
|
||||
currentRoom.peers.push(createPeerData(data));
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
sendChatActivity('joined', data.peerId, Date.now());
|
||||
showNotification(data.username || data.peerId, 'joined');
|
||||
|
||||
// We were alone and now we're not — proactively push our
|
||||
// current playback state so the newcomer syncs immediately
|
||||
@@ -1416,6 +1468,9 @@ async function handleServerEvent(event, data) {
|
||||
}
|
||||
}
|
||||
} else if (data.status === 'left') {
|
||||
const departedDisplayName = chatActivityDisplayName(data.peerId);
|
||||
sendChatActivity('left', data.peerId, Date.now());
|
||||
showNotification(departedDisplayName, 'left');
|
||||
currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId);
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
@@ -1577,6 +1632,7 @@ function executeForceSync() {
|
||||
|
||||
emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq });
|
||||
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq });
|
||||
sendChatActivity(EVENTS.FORCE_SYNC_EXECUTE, peerId, executionTimestamp);
|
||||
addLog('Force Sync Executed', 'success');
|
||||
}
|
||||
|
||||
@@ -2554,7 +2610,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local' || (!changes.roomId && !changes.chatKey && !changes.chatEnabled)) return;
|
||||
if (area !== 'local') return;
|
||||
if (changes.browserNotifications && currentTabId) {
|
||||
chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
}
|
||||
if (!changes.roomId && !changes.chatKey && !changes.chatEnabled) return;
|
||||
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
|
||||
invalidateChatSession();
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
@@ -2661,7 +2721,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return;
|
||||
}
|
||||
const settings = await getSettings();
|
||||
const localeData = await chrome.storage.local.get(['locale']);
|
||||
const localeData = await chrome.storage.local.get(['locale', 'browserNotifications']);
|
||||
await loadLocale(localeData.locale || getSystemLanguage());
|
||||
const translated = key => {
|
||||
const value = getMessage(key);
|
||||
@@ -2672,6 +2732,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
enabled: settings.chatEnabled,
|
||||
hasKey: !!settings.chatKey,
|
||||
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
|
||||
eventNotifications: localeData.browserNotifications === true,
|
||||
peerId,
|
||||
roomId: currentRoom.roomId,
|
||||
strings: {
|
||||
@@ -2688,7 +2749,15 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
tooLong: translated('CHAT_TOO_LONG'),
|
||||
sendFailed: translated('CHAT_SEND_FAILED'),
|
||||
empty: translated('CHAT_EMPTY'),
|
||||
you: translated('LABEL_YOU')
|
||||
you: translated('LABEL_YOU'),
|
||||
eventPlay: translated('NOTIF_PLAY'),
|
||||
eventPause: translated('NOTIF_PAUSE'),
|
||||
eventSeek: translated('NOTIF_SEEK'),
|
||||
eventForcePrepare: translated('NOTIF_FORCE_PREPARE'),
|
||||
eventForceExecute: translated('NOTIF_FORCE_EXECUTE'),
|
||||
eventAction: translated('TOAST_PEER_ACTION'),
|
||||
eventJoined: translated('TOAST_PEER_JOINED'),
|
||||
eventLeft: translated('TOAST_PEER_LEFT')
|
||||
}
|
||||
});
|
||||
} else if (message.type === 'CHAT_SEND') {
|
||||
@@ -3127,6 +3196,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}, FORCE_SYNC_TIMEOUT);
|
||||
}
|
||||
addToHistory(message.action, 'You');
|
||||
sendChatActivity(message.action, peerId, timestamp);
|
||||
|
||||
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
|
||||
if (isNonEssentialEvent && !hasOtherPeers) {
|
||||
|
||||
@@ -52,6 +52,54 @@ describe('chat overlay contract', () => {
|
||||
expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))');
|
||||
});
|
||||
|
||||
it('blocks page playback shortcuts while the chat composer owns keyboard input', () => {
|
||||
expect(overlaySource).toContain("for (const eventName of ['keydown', 'keyup', 'keypress'])");
|
||||
expect(overlaySource).toContain('window.addEventListener(eventName, stopPageKeyboardShortcut, true)');
|
||||
expect(overlaySource).toContain('path.includes(textarea)');
|
||||
expect(overlaySource).toContain('event.stopImmediatePropagation()');
|
||||
expect(overlaySource).toContain('window.removeEventListener(eventName, stopPageKeyboardShortcut, true)');
|
||||
});
|
||||
|
||||
it('keeps the launcher draggable independently from dock mode', () => {
|
||||
expect(overlaySource).toContain("launcher.addEventListener('pointerdown'");
|
||||
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`/);
|
||||
});
|
||||
|
||||
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('applyPageDock(layout.mode, dockWidth)');
|
||||
expect(overlaySource).toContain('clearPageDock()');
|
||||
});
|
||||
|
||||
it('counts unread remote messages on the bubble and clears the badge when opened', () => {
|
||||
expect(overlaySource).toContain("const unreadBadge = element('span', 'unread')");
|
||||
expect(overlaySource).toContain('if (!opened && !own) setUnreadCount(unreadCount + 1)');
|
||||
expect(overlaySource).toMatch(/if \(opened\) \{\s*setUnreadCount\(0\)/);
|
||||
expect(overlaySource).toContain("unreadBadge.classList.toggle('visible', unreadCount > 0)");
|
||||
expect(backgroundSource).toMatch(/received\.senderId !== peerId[\s\S]*showChatNotification\([\s\S]*senderPeer\.username[\s\S]*received\.senderId/);
|
||||
expect(backgroundSource).toContain("chrome.notifications.create(`chat_${Date.now()}`");
|
||||
expect(backgroundSource).toContain("if (settings.chatNotifications === false) return");
|
||||
});
|
||||
|
||||
it('renders timestamped room activity and keeps activity notifications opt-in', () => {
|
||||
expect(overlaySource).toContain("if (message?.type === 'CHAT_EVENT') appendEvent(message.event)");
|
||||
expect(overlaySource).toContain("const time = element('time', 'time')");
|
||||
expect(overlaySource).toContain("date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })");
|
||||
expect(overlaySource).toContain('if (!opened && context.eventNotifications) setUnreadCount(unreadCount + 1)');
|
||||
expect(backgroundSource).toContain("eventNotifications: localeData.browserNotifications === true");
|
||||
expect(backgroundSource).toContain("sendChatActivity(message.action, peerId, timestamp)");
|
||||
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())");
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
+205
-21
@@ -9,6 +9,11 @@
|
||||
const MIN_HEIGHT = 320;
|
||||
const DEFAULT_WIDTH = 360;
|
||||
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 SIZE_PRESETS = Object.freeze({
|
||||
compact: Object.freeze({ width: 320, height: 400 }),
|
||||
standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }),
|
||||
@@ -26,6 +31,7 @@
|
||||
let refreshGeneration = 0;
|
||||
let sendGeneration = 0;
|
||||
let sending = false;
|
||||
let unreadCount = 0;
|
||||
let layout = {
|
||||
mode: 'right',
|
||||
x: 24,
|
||||
@@ -34,7 +40,10 @@
|
||||
height: DEFAULT_HEIGHT,
|
||||
customWidth: DEFAULT_WIDTH,
|
||||
customHeight: DEFAULT_HEIGHT,
|
||||
detachedInitialized: false
|
||||
detachedInitialized: false,
|
||||
launcherX: null,
|
||||
launcherY: null,
|
||||
launcherInitialized: false
|
||||
};
|
||||
let chatPosition = 'right';
|
||||
let chatSize = 'standard';
|
||||
@@ -106,9 +115,18 @@
|
||||
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;
|
||||
touch-action: none; user-select: none;
|
||||
}
|
||||
.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; }
|
||||
.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);
|
||||
border-radius: 999px; background: var(--danger); color: white; font-size: 11px; font-weight: 800;
|
||||
line-height: 1; pointer-events: none;
|
||||
}
|
||||
.unread.visible { display: flex; }
|
||||
.panel {
|
||||
position: fixed; display: none; flex-direction: column; overflow: hidden;
|
||||
min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px);
|
||||
@@ -117,6 +135,7 @@
|
||||
box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0);
|
||||
}
|
||||
.panel.open { display: flex; }
|
||||
.panel.docked { border-radius: 0; max-height: 100vh; }
|
||||
.header { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border-soft); user-select: none; }
|
||||
.header.detached { cursor: move; }
|
||||
.heading { min-width: 0; flex: 1; }
|
||||
@@ -132,6 +151,12 @@
|
||||
.sender { color: var(--accent); font-weight: 700; font-size: 12px; }
|
||||
.time { color: var(--text-muted); font-size: 10px; }
|
||||
.body { white-space: pre-wrap; color: var(--text); }
|
||||
.event-message {
|
||||
display: flex; align-items: center; justify-content: center; gap: 7px; margin: 8px 0;
|
||||
color: var(--text-muted); font-size: 11px; text-align: center;
|
||||
}
|
||||
.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); }
|
||||
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); }
|
||||
@@ -156,8 +181,12 @@
|
||||
|
||||
const app = element('div');
|
||||
app.id = 'app';
|
||||
const launcher = element('button', 'launcher', '💬');
|
||||
const launcher = element('button', 'launcher');
|
||||
launcher.type = 'button';
|
||||
const launcherIcon = element('span', 'launcher-icon', '💬');
|
||||
const unreadBadge = element('span', 'unread');
|
||||
unreadBadge.setAttribute('aria-hidden', 'true');
|
||||
launcher.append(launcherIcon, unreadBadge);
|
||||
const launcherHint = element('span', 'visually-hidden');
|
||||
launcherHint.id = 'chat-launcher-hint';
|
||||
const panel = element('section', 'panel');
|
||||
@@ -169,7 +198,7 @@
|
||||
heading.append(title, subtitle);
|
||||
const modes = element('div', 'modes');
|
||||
const leftButton = element('button', 'icon-button', '←');
|
||||
const detachedButton = element('button', 'icon-button', '↗');
|
||||
const detachedButton = element('button', 'icon-button', '✥');
|
||||
const rightButton = element('button', 'icon-button', '→');
|
||||
const closeButton = element('button', 'icon-button', '×');
|
||||
for (const button of [leftButton, detachedButton, rightButton, closeButton]) button.type = 'button';
|
||||
@@ -199,6 +228,19 @@
|
||||
app.append(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"] {
|
||||
box-sizing: border-box !important;
|
||||
padding-left: var(${PAGE_DOCK_WIDTH}) !important;
|
||||
}
|
||||
html[${PAGE_DOCK_ATTRIBUTE}="right"] {
|
||||
box-sizing: border-box !important;
|
||||
padding-right: var(${PAGE_DOCK_WIDTH}) !important;
|
||||
}
|
||||
`;
|
||||
document.documentElement.append(pageDockStyle);
|
||||
|
||||
function messageRuntime(payload, timeoutMs = 5000) {
|
||||
return new Promise(resolve => {
|
||||
@@ -250,6 +292,7 @@
|
||||
detachedButton.setAttribute('aria-label', text.detached || '');
|
||||
rightButton.setAttribute('aria-label', text.dockRight || '');
|
||||
closeButton.setAttribute('aria-label', text.close || '');
|
||||
applyUnreadCount();
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
@@ -277,6 +320,52 @@
|
||||
};
|
||||
}
|
||||
|
||||
function clampLauncher() {
|
||||
const viewport = viewportBounds();
|
||||
const maxX = Math.max(0, viewport.width - LAUNCHER_SIZE - EDGE_INSET);
|
||||
const maxY = Math.max(0, viewport.height - LAUNCHER_SIZE - EDGE_INSET);
|
||||
layout.launcherX = Math.min(Math.max(Number(layout.launcherX) || EDGE_INSET, EDGE_INSET), maxX);
|
||||
layout.launcherY = Math.min(Math.max(Number(layout.launcherY) || EDGE_INSET, EDGE_INSET), maxY);
|
||||
}
|
||||
|
||||
function initializeLauncher() {
|
||||
if (layout.launcherInitialized) return;
|
||||
const viewport = viewportBounds();
|
||||
layout.launcherX = chatPosition === 'left'
|
||||
? Math.min(16, Math.max(EDGE_INSET, viewport.width - LAUNCHER_SIZE - EDGE_INSET))
|
||||
: Math.max(EDGE_INSET, viewport.width - LAUNCHER_SIZE - 16);
|
||||
layout.launcherY = Math.max(EDGE_INSET, Math.round(viewport.height / 2 - LAUNCHER_SIZE / 2));
|
||||
layout.launcherInitialized = true;
|
||||
clampLauncher();
|
||||
}
|
||||
|
||||
function clearPageDock() {
|
||||
document.documentElement.removeAttribute(PAGE_DOCK_ATTRIBUTE);
|
||||
document.documentElement.style.removeProperty(PAGE_DOCK_WIDTH);
|
||||
}
|
||||
|
||||
function applyPageDock(side, width) {
|
||||
const viewport = viewportBounds();
|
||||
if (!opened || document.fullscreenElement || viewport.width - width < DOCK_MIN_PAGE_WIDTH) {
|
||||
clearPageDock();
|
||||
return;
|
||||
}
|
||||
document.documentElement.setAttribute(PAGE_DOCK_ATTRIBUTE, side);
|
||||
document.documentElement.style.setProperty(PAGE_DOCK_WIDTH, `${width}px`);
|
||||
}
|
||||
|
||||
function applyUnreadCount() {
|
||||
unreadBadge.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||
unreadBadge.classList.toggle('visible', unreadCount > 0);
|
||||
const openLabel = strings().open || '';
|
||||
launcher.setAttribute('aria-label', unreadCount > 0 ? `${openLabel} (${unreadCount})` : openLabel);
|
||||
}
|
||||
|
||||
function setUnreadCount(next) {
|
||||
unreadCount = Math.max(0, Number.isFinite(next) ? Math.trunc(next) : 0);
|
||||
applyUnreadCount();
|
||||
}
|
||||
|
||||
function clampDetached() {
|
||||
const viewport = viewportBounds();
|
||||
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
|
||||
@@ -300,35 +389,37 @@
|
||||
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`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.style.resize = 'none';
|
||||
panel.classList.toggle('docked', layout.mode !== 'detached');
|
||||
header.classList.toggle('detached', layout.mode === 'detached');
|
||||
leftButton.classList.toggle('active', layout.mode === 'left');
|
||||
detachedButton.classList.toggle('active', layout.mode === 'detached');
|
||||
rightButton.classList.toggle('active', layout.mode === 'right');
|
||||
if (layout.mode === 'detached') {
|
||||
clearPageDock();
|
||||
clampDetached();
|
||||
panel.style.left = `${layout.x}px`;
|
||||
panel.style.top = `${layout.y}px`;
|
||||
panel.style.width = `${layout.width}px`;
|
||||
panel.style.height = `${layout.height}px`;
|
||||
panel.style.resize = 'both';
|
||||
launcher.style.left = `${layout.x}px`;
|
||||
launcher.style.right = 'auto';
|
||||
launcher.style.top = `${layout.y}px`;
|
||||
} else {
|
||||
const viewport = viewportBounds();
|
||||
const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2)));
|
||||
const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64;
|
||||
panel.style.top = `${panelTop}px`;
|
||||
panel.style.width = `${Math.max(1, Math.min(size.width, viewport.width - gutter * 2))}px`;
|
||||
panel.style.height = `${Math.max(1, Math.min(size.height, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`;
|
||||
panel.style[layout.mode] = `${gutter}px`;
|
||||
launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`;
|
||||
launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto';
|
||||
launcher.style.right = layout.mode === 'right' ? `${gutter}px` : 'auto';
|
||||
const dockWidth = Math.max(1, Math.min(size.width, viewport.width - 16));
|
||||
panel.style.top = '0';
|
||||
panel.style.bottom = '0';
|
||||
panel.style.width = `${dockWidth}px`;
|
||||
panel.style.height = `${viewport.height}px`;
|
||||
panel.style[layout.mode] = '0';
|
||||
applyPageDock(layout.mode, dockWidth);
|
||||
}
|
||||
globalThis.queueMicrotask(() => { applyingLayout = false; });
|
||||
}
|
||||
@@ -369,9 +460,11 @@
|
||||
panel.classList.toggle('open', opened);
|
||||
launcher.style.display = opened ? 'none' : '';
|
||||
if (opened) {
|
||||
setUnreadCount(0);
|
||||
textarea.focus();
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
applyLayout();
|
||||
}
|
||||
|
||||
function applyContext(next) {
|
||||
@@ -413,6 +506,7 @@
|
||||
const wrapper = element('article', 'message');
|
||||
const meta = element('div', 'meta');
|
||||
const own = message.senderId === context.peerId;
|
||||
if (!opened && !own) setUnreadCount(unreadCount + 1);
|
||||
const sender = element('span', 'sender', own ? (strings().you || '') : (message.username || message.senderId || ''));
|
||||
const time = element('time', 'time');
|
||||
const date = new Date(message.timestamp);
|
||||
@@ -431,8 +525,44 @@
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function appendEvent(event) {
|
||||
if (!context?.enabled || !event || typeof event.action !== 'string') return;
|
||||
const own = event.senderId === context.peerId;
|
||||
const displayName = own ? (strings().you || '') : (event.username || event.senderId || '');
|
||||
const labels = {
|
||||
play: strings().eventPlay,
|
||||
pause: strings().eventPause,
|
||||
seek: strings().eventSeek,
|
||||
force_sync_prepare: strings().eventForcePrepare,
|
||||
force_sync_execute: strings().eventForceExecute
|
||||
};
|
||||
let eventText = '';
|
||||
if (event.action === 'joined' || event.action === 'left') {
|
||||
const template = event.action === 'joined' ? strings().eventJoined : strings().eventLeft;
|
||||
eventText = (template || '{name}').replace('{name}', displayName);
|
||||
} else if (labels[event.action]) {
|
||||
eventText = (strings().eventAction || '{name} {action}')
|
||||
.replace('{name}', displayName)
|
||||
.replace('{action}', labels[event.action]);
|
||||
}
|
||||
if (!eventText) return;
|
||||
|
||||
if (empty.isConnected) empty.remove();
|
||||
if (!opened && context.eventNotifications) setUnreadCount(unreadCount + 1);
|
||||
const wrapper = element('article', 'message event-message');
|
||||
const body = element('span', 'event-text', eventText);
|
||||
const time = element('time', 'time');
|
||||
const date = new Date(event.timestamp);
|
||||
time.textContent = Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
wrapper.append(body, time);
|
||||
messages.append(wrapper);
|
||||
while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove();
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messages.replaceChildren(empty);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
|
||||
function resetComposer() {
|
||||
@@ -445,7 +575,48 @@
|
||||
sendButton.disabled = false;
|
||||
}
|
||||
|
||||
launcher.addEventListener('click', () => setOpened(true));
|
||||
let launcherDrag = null;
|
||||
let suppressLauncherClick = false;
|
||||
launcher.addEventListener('click', event => {
|
||||
if (suppressLauncherClick && event.detail !== 0) {
|
||||
suppressLauncherClick = false;
|
||||
return;
|
||||
}
|
||||
suppressLauncherClick = false;
|
||||
setOpened(true);
|
||||
});
|
||||
launcher.addEventListener('pointerdown', event => {
|
||||
launcherDrag = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
x: layout.launcherX,
|
||||
y: layout.launcherY,
|
||||
moved: false
|
||||
};
|
||||
launcher.setPointerCapture(event.pointerId);
|
||||
});
|
||||
launcher.addEventListener('pointermove', event => {
|
||||
if (!launcherDrag || event.pointerId !== launcherDrag.pointerId) return;
|
||||
const deltaX = event.clientX - launcherDrag.startX;
|
||||
const deltaY = event.clientY - launcherDrag.startY;
|
||||
if (Math.hypot(deltaX, deltaY) >= 4) launcherDrag.moved = true;
|
||||
layout.launcherX = launcherDrag.x + deltaX;
|
||||
layout.launcherY = launcherDrag.y + deltaY;
|
||||
clampLauncher();
|
||||
launcher.style.left = `${layout.launcherX}px`;
|
||||
launcher.style.top = `${layout.launcherY}px`;
|
||||
});
|
||||
launcher.addEventListener('pointerup', event => {
|
||||
if (!launcherDrag || event.pointerId !== launcherDrag.pointerId) return;
|
||||
suppressLauncherClick = launcherDrag.moved;
|
||||
launcherDrag = null;
|
||||
saveLayout();
|
||||
});
|
||||
launcher.addEventListener('pointercancel', () => {
|
||||
launcherDrag = null;
|
||||
suppressLauncherClick = false;
|
||||
});
|
||||
closeButton.addEventListener('click', () => setOpened(false));
|
||||
leftButton.addEventListener('click', () => setMode('left'));
|
||||
detachedButton.addEventListener('click', () => setMode('detached'));
|
||||
@@ -457,12 +628,18 @@
|
||||
count.style.color = length > 500 ? 'var(--danger)' : '';
|
||||
status.textContent = length > 500 ? (strings().tooLong || '') : '';
|
||||
});
|
||||
textarea.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
const stopPageKeyboardShortcut = event => {
|
||||
const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
|
||||
if (!path.includes(textarea) && !path.includes(composer)) return;
|
||||
if (event.type === 'keydown' && event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (!sending) composer.requestSubmit();
|
||||
}
|
||||
});
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
for (const eventName of ['keydown', 'keyup', 'keypress']) {
|
||||
window.addEventListener(eventName, stopPageKeyboardShortcut, true);
|
||||
}
|
||||
composer.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (sending || !context?.enabled) return;
|
||||
@@ -535,10 +712,11 @@
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
if (layout.mode === 'detached') {
|
||||
clampDetached();
|
||||
clampLauncher();
|
||||
applyLayout();
|
||||
saveLayout();
|
||||
if (layout.mode === 'detached') {
|
||||
clampDetached();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,6 +743,7 @@
|
||||
|
||||
function handleRuntime(message) {
|
||||
if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message);
|
||||
if (message?.type === 'CHAT_EVENT') appendEvent(message.event);
|
||||
if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh();
|
||||
if (message?.type === 'CHAT_RESET') {
|
||||
clearMessages();
|
||||
@@ -581,6 +760,11 @@
|
||||
sendGeneration++;
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
resizeObserver.disconnect();
|
||||
clearPageDock();
|
||||
pageDockStyle.remove();
|
||||
for (const eventName of ['keydown', 'keyup', 'keypress']) {
|
||||
window.removeEventListener(eventName, stopPageKeyboardShortcut, true);
|
||||
}
|
||||
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
systemTheme.removeEventListener('change', handleSystemTheme);
|
||||
|
||||
@@ -1632,6 +1632,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-row" data-chat-setting>
|
||||
<label for="chatNotifications" data-i18n="LABEL_NOTIFICATIONS">Browser Notifications</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="chatNotifications">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-row" data-chat-setting>
|
||||
<label for="chatPosition" title="Choose where chat is attached to the selected player." data-i18n="LABEL_CHAT_POSITION" data-i18n-title="LABEL_CHAT_POSITION_TOOLTIP">Position</label>
|
||||
<span class="settings-select">
|
||||
|
||||
+9
-2
@@ -69,6 +69,7 @@ const elements = {
|
||||
pauseBtn: document.getElementById('pauseBtn'),
|
||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||
chatEnabled: document.getElementById('chatEnabled'),
|
||||
chatNotifications: document.getElementById('chatNotifications'),
|
||||
chatPosition: document.getElementById('chatPosition'),
|
||||
chatSize: document.getElementById('chatSize'),
|
||||
chatStartMode: document.getElementById('chatStartMode'),
|
||||
@@ -341,7 +342,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', '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', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
||||
|
||||
let activeLang = localData.locale;
|
||||
if (!activeLang) {
|
||||
@@ -373,6 +374,7 @@ async function init() {
|
||||
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
||||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
||||
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
|
||||
if (elements.chatNotifications) elements.chatNotifications.checked = localData.chatNotifications !== false;
|
||||
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';
|
||||
@@ -1467,7 +1469,7 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
|
||||
function syncChatSettingsState() {
|
||||
const enabled = elements.chatEnabled?.checked === true;
|
||||
for (const control of [elements.chatPosition, elements.chatSize, elements.chatStartMode]) {
|
||||
for (const control of [elements.chatNotifications, elements.chatPosition, elements.chatSize, elements.chatStartMode]) {
|
||||
if (control) control.disabled = !enabled;
|
||||
}
|
||||
document.querySelectorAll('[data-chat-setting]').forEach(row => {
|
||||
@@ -1482,6 +1484,11 @@ if (elements.chatEnabled) {
|
||||
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
|
||||
});
|
||||
}
|
||||
if (elements.chatNotifications) {
|
||||
elements.chatNotifications.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ chatNotifications: elements.chatNotifications.checked });
|
||||
});
|
||||
}
|
||||
if (elements.chatPosition) {
|
||||
elements.chatPosition.addEventListener('change', () => {
|
||||
const chatPosition = ['left', 'detached'].includes(elements.chatPosition.value) ? elements.chatPosition.value : 'right';
|
||||
|
||||
@@ -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', 'chatPosition', 'chatSize', 'chatStartMode']) {
|
||||
for (const id of ['chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode']) {
|
||||
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');
|
||||
@@ -38,7 +38,7 @@ for (const value of ['bubble', 'open']) {
|
||||
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat start mode supports ${value}`);
|
||||
}
|
||||
|
||||
for (const key of ['chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode']) {
|
||||
for (const key of ['chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode']) {
|
||||
assert.match(popupJs, new RegExp(`chrome\\.storage\\.local\\.set\\(\\{ ${key}`), `popup persists ${key}`);
|
||||
assert.ok(backgroundJs.includes(`'${key}'`), `legacy sync purge includes ${key}`);
|
||||
}
|
||||
@@ -46,6 +46,9 @@ for (const key of ['chatPosition', 'chatSize', 'chatStartMode']) {
|
||||
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');
|
||||
assert.match(backgroundJs, /chatNotifications:\s*data\.chatNotifications !== false/, 'chat message notifications default on');
|
||||
assert.match(popupJs, /elements\.chatNotifications\.checked = localData\.chatNotifications !== false/, 'popup shows chat notifications on by default');
|
||||
assert.match(popupJs, /elements\.browserNotifications\.checked = localData\.browserNotifications === true/, 'activity notifications remain off by default');
|
||||
assert.match(overlayJs, /context\?\.enabled/, 'overlay consumes chat enablement from the validated background context');
|
||||
|
||||
assert.match(overlayJs, /SIZE_PRESETS[\s\S]*compact[\s\S]*standard[\s\S]*large/, 'overlay defines all size presets');
|
||||
|
||||
Reference in New Issue
Block a user