mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-31 13: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
|
// (username) must NEVER come from storage.sync — syncing them across devices
|
||||||
// both leaks them and resurrects dead rooms on reinstall (a fresh install
|
// 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).
|
// 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;
|
let username = data.username;
|
||||||
if (!username) {
|
if (!username) {
|
||||||
username = generateUsername();
|
username = generateUsername();
|
||||||
@@ -417,6 +417,7 @@ async function getSettings() {
|
|||||||
password: data.password || '',
|
password: data.password || '',
|
||||||
chatKey,
|
chatKey,
|
||||||
chatEnabled: data.chatEnabled === true,
|
chatEnabled: data.chatEnabled === true,
|
||||||
|
chatNotifications: data.chatNotifications !== false,
|
||||||
username,
|
username,
|
||||||
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
|
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
|
||||||
mediaTitlePrivacyMode
|
mediaTitlePrivacyMode
|
||||||
@@ -453,7 +454,7 @@ function emitEpisodeLobbyForCurrentPrivacy() {
|
|||||||
// otherwise be redistributed across devices and resurrected on reinstall).
|
// otherwise be redistributed across devices and resurrected on reinstall).
|
||||||
const LEGACY_SYNC_KEYS = [
|
const LEGACY_SYNC_KEYS = [
|
||||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey',
|
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey',
|
||||||
'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username',
|
'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'username',
|
||||||
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
||||||
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
|
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
|
||||||
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
|
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
|
||||||
@@ -907,7 +908,11 @@ function showNotification(senderName, action) {
|
|||||||
displayName = getMessage('LABEL_YOU') || 'YOU';
|
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()}`, {
|
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||||
type: 'basic',
|
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() {
|
function scheduleReconnect() {
|
||||||
if (reconnectTimer) return;
|
if (reconnectTimer) return;
|
||||||
|
|
||||||
@@ -1235,6 +1277,12 @@ async function handleServerEvent(event, data) {
|
|||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
if (received.senderId !== peerId) {
|
||||||
|
showChatNotification(
|
||||||
|
typeof senderPeer === 'object' ? senderPeer.username : received.senderId,
|
||||||
|
text
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn');
|
if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn');
|
||||||
}
|
}
|
||||||
@@ -1291,6 +1339,7 @@ async function handleServerEvent(event, data) {
|
|||||||
if (data.senderId) {
|
if (data.senderId) {
|
||||||
addToHistory(event, data.senderId);
|
addToHistory(event, data.senderId);
|
||||||
showNotification(data.senderId, event);
|
showNotification(data.senderId, event);
|
||||||
|
sendChatActivity(event, data.senderId, data.actionTimestamp);
|
||||||
updateLastAction(event, data.senderId);
|
updateLastAction(event, data.senderId);
|
||||||
lastActionState.targetTime = data.targetTime !== undefined ? data.targetTime : data.currentTime;
|
lastActionState.targetTime = data.targetTime !== undefined ? data.targetTime : data.currentTime;
|
||||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||||
@@ -1348,6 +1397,7 @@ async function handleServerEvent(event, data) {
|
|||||||
if (data?.senderId) {
|
if (data?.senderId) {
|
||||||
addToHistory(event, data.senderId);
|
addToHistory(event, data.senderId);
|
||||||
showNotification(data.senderId, event);
|
showNotification(data.senderId, event);
|
||||||
|
sendChatActivity(event, data.senderId, data.actionTimestamp);
|
||||||
|
|
||||||
// (The sender's state is updated below with everyone else)
|
// (The sender's state is updated below with everyone else)
|
||||||
}
|
}
|
||||||
@@ -1403,6 +1453,8 @@ async function handleServerEvent(event, data) {
|
|||||||
currentRoom.peers.push(createPeerData(data));
|
currentRoom.peers.push(createPeerData(data));
|
||||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
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
|
// We were alone and now we're not — proactively push our
|
||||||
// current playback state so the newcomer syncs immediately
|
// current playback state so the newcomer syncs immediately
|
||||||
@@ -1416,6 +1468,9 @@ async function handleServerEvent(event, data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (data.status === 'left') {
|
} 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);
|
currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId);
|
||||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
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 });
|
emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq });
|
||||||
routeToContent(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');
|
addLog('Force Sync Executed', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2554,7 +2610,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener((changes, area) => {
|
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);
|
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
|
||||||
invalidateChatSession();
|
invalidateChatSession();
|
||||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||||
@@ -2661,7 +2721,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const settings = await getSettings();
|
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());
|
await loadLocale(localeData.locale || getSystemLanguage());
|
||||||
const translated = key => {
|
const translated = key => {
|
||||||
const value = getMessage(key);
|
const value = getMessage(key);
|
||||||
@@ -2672,6 +2732,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
enabled: settings.chatEnabled,
|
enabled: settings.chatEnabled,
|
||||||
hasKey: !!settings.chatKey,
|
hasKey: !!settings.chatKey,
|
||||||
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
|
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
|
||||||
|
eventNotifications: localeData.browserNotifications === true,
|
||||||
peerId,
|
peerId,
|
||||||
roomId: currentRoom.roomId,
|
roomId: currentRoom.roomId,
|
||||||
strings: {
|
strings: {
|
||||||
@@ -2688,7 +2749,15 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
tooLong: translated('CHAT_TOO_LONG'),
|
tooLong: translated('CHAT_TOO_LONG'),
|
||||||
sendFailed: translated('CHAT_SEND_FAILED'),
|
sendFailed: translated('CHAT_SEND_FAILED'),
|
||||||
empty: translated('CHAT_EMPTY'),
|
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') {
|
} else if (message.type === 'CHAT_SEND') {
|
||||||
@@ -3127,6 +3196,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
}, FORCE_SYNC_TIMEOUT);
|
}, FORCE_SYNC_TIMEOUT);
|
||||||
}
|
}
|
||||||
addToHistory(message.action, 'You');
|
addToHistory(message.action, 'You');
|
||||||
|
sendChatActivity(message.action, peerId, timestamp);
|
||||||
|
|
||||||
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
|
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
|
||||||
if (isNonEssentialEvent && !hasOtherPeers) {
|
if (isNonEssentialEvent && !hasOtherPeers) {
|
||||||
|
|||||||
@@ -52,6 +52,54 @@ describe('chat overlay contract', () => {
|
|||||||
expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))');
|
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', () => {
|
it('guards async refresh/send work and clears all composer state on room reset', () => {
|
||||||
expect(overlaySource).toContain('generation !== refreshGeneration');
|
expect(overlaySource).toContain('generation !== refreshGeneration');
|
||||||
expect(overlaySource).toContain('if (sending || !context?.enabled) return');
|
expect(overlaySource).toContain('if (sending || !context?.enabled) return');
|
||||||
|
|||||||
+216
-32
@@ -9,6 +9,11 @@
|
|||||||
const MIN_HEIGHT = 320;
|
const MIN_HEIGHT = 320;
|
||||||
const DEFAULT_WIDTH = 360;
|
const DEFAULT_WIDTH = 360;
|
||||||
const DEFAULT_HEIGHT = 520;
|
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({
|
const SIZE_PRESETS = Object.freeze({
|
||||||
compact: Object.freeze({ width: 320, height: 400 }),
|
compact: Object.freeze({ width: 320, height: 400 }),
|
||||||
standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }),
|
standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }),
|
||||||
@@ -26,6 +31,7 @@
|
|||||||
let refreshGeneration = 0;
|
let refreshGeneration = 0;
|
||||||
let sendGeneration = 0;
|
let sendGeneration = 0;
|
||||||
let sending = false;
|
let sending = false;
|
||||||
|
let unreadCount = 0;
|
||||||
let layout = {
|
let layout = {
|
||||||
mode: 'right',
|
mode: 'right',
|
||||||
x: 24,
|
x: 24,
|
||||||
@@ -34,7 +40,10 @@
|
|||||||
height: DEFAULT_HEIGHT,
|
height: DEFAULT_HEIGHT,
|
||||||
customWidth: DEFAULT_WIDTH,
|
customWidth: DEFAULT_WIDTH,
|
||||||
customHeight: DEFAULT_HEIGHT,
|
customHeight: DEFAULT_HEIGHT,
|
||||||
detachedInitialized: false
|
detachedInitialized: false,
|
||||||
|
launcherX: null,
|
||||||
|
launcherY: null,
|
||||||
|
launcherInitialized: false
|
||||||
};
|
};
|
||||||
let chatPosition = 'right';
|
let chatPosition = 'right';
|
||||||
let chatSize = 'standard';
|
let chatSize = 'standard';
|
||||||
@@ -103,21 +112,31 @@
|
|||||||
button, textarea { font: inherit; }
|
button, textarea { font: inherit; }
|
||||||
button { color: inherit; }
|
button { color: inherit; }
|
||||||
.launcher {
|
.launcher {
|
||||||
position: fixed; width: 48px; height: 48px; border: 1px solid var(--border-strong);
|
position: fixed; width: 48px; height: 48px; border: 1px solid var(--border-strong);
|
||||||
border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer;
|
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); 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:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); }
|
||||||
.panel {
|
.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;
|
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);
|
min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px);
|
||||||
max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text);
|
max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text);
|
||||||
background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px;
|
background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px;
|
||||||
box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0);
|
box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0);
|
||||||
}
|
}
|
||||||
.panel.open { display: flex; }
|
.panel.open { display: flex; }
|
||||||
.header { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border-soft); user-select: none; }
|
.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; }
|
.header.detached { cursor: move; }
|
||||||
.heading { min-width: 0; flex: 1; }
|
.heading { min-width: 0; flex: 1; }
|
||||||
.title { font-size: 14px; font-weight: 760; }
|
.title { font-size: 14px; font-weight: 760; }
|
||||||
@@ -131,7 +150,13 @@
|
|||||||
.meta { display: flex; align-items: baseline; gap: 7px; margin-bottom: 2px; }
|
.meta { display: flex; align-items: baseline; gap: 7px; margin-bottom: 2px; }
|
||||||
.sender { color: var(--accent); font-weight: 700; font-size: 12px; }
|
.sender { color: var(--accent); font-weight: 700; font-size: 12px; }
|
||||||
.time { color: var(--text-muted); font-size: 10px; }
|
.time { color: var(--text-muted); font-size: 10px; }
|
||||||
.body { white-space: pre-wrap; color: var(--text); }
|
.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); }
|
.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 { 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:focus { border-color: var(--accent); }
|
||||||
@@ -156,8 +181,12 @@
|
|||||||
|
|
||||||
const app = element('div');
|
const app = element('div');
|
||||||
app.id = 'app';
|
app.id = 'app';
|
||||||
const launcher = element('button', 'launcher', '💬');
|
const launcher = element('button', 'launcher');
|
||||||
launcher.type = 'button';
|
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');
|
const launcherHint = element('span', 'visually-hidden');
|
||||||
launcherHint.id = 'chat-launcher-hint';
|
launcherHint.id = 'chat-launcher-hint';
|
||||||
const panel = element('section', 'panel');
|
const panel = element('section', 'panel');
|
||||||
@@ -169,7 +198,7 @@
|
|||||||
heading.append(title, subtitle);
|
heading.append(title, subtitle);
|
||||||
const modes = element('div', 'modes');
|
const modes = element('div', 'modes');
|
||||||
const leftButton = element('button', 'icon-button', '←');
|
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 rightButton = element('button', 'icon-button', '→');
|
||||||
const closeButton = element('button', 'icon-button', '×');
|
const closeButton = element('button', 'icon-button', '×');
|
||||||
for (const button of [leftButton, detachedButton, rightButton, closeButton]) button.type = 'button';
|
for (const button of [leftButton, detachedButton, rightButton, closeButton]) button.type = 'button';
|
||||||
@@ -199,6 +228,19 @@
|
|||||||
app.append(launcherHint, launcher, panel);
|
app.append(launcherHint, launcher, panel);
|
||||||
shadow.append(style, app);
|
shadow.append(style, app);
|
||||||
document.documentElement.append(host);
|
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) {
|
function messageRuntime(payload, timeoutMs = 5000) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
@@ -250,6 +292,7 @@
|
|||||||
detachedButton.setAttribute('aria-label', text.detached || '');
|
detachedButton.setAttribute('aria-label', text.detached || '');
|
||||||
rightButton.setAttribute('aria-label', text.dockRight || '');
|
rightButton.setAttribute('aria-label', text.dockRight || '');
|
||||||
closeButton.setAttribute('aria-label', text.close || '');
|
closeButton.setAttribute('aria-label', text.close || '');
|
||||||
|
applyUnreadCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme() {
|
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() {
|
function clampDetached() {
|
||||||
const viewport = viewportBounds();
|
const viewport = viewportBounds();
|
||||||
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
|
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
|
||||||
@@ -300,35 +389,37 @@
|
|||||||
function applyLayout() {
|
function applyLayout() {
|
||||||
applyingLayout = true;
|
applyingLayout = true;
|
||||||
const size = preferredSize();
|
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.right = 'auto';
|
||||||
panel.style.left = 'auto';
|
panel.style.left = 'auto';
|
||||||
panel.style.bottom = 'auto';
|
panel.style.bottom = 'auto';
|
||||||
panel.style.resize = 'none';
|
panel.style.resize = 'none';
|
||||||
|
panel.classList.toggle('docked', layout.mode !== 'detached');
|
||||||
header.classList.toggle('detached', layout.mode === 'detached');
|
header.classList.toggle('detached', layout.mode === 'detached');
|
||||||
leftButton.classList.toggle('active', layout.mode === 'left');
|
leftButton.classList.toggle('active', layout.mode === 'left');
|
||||||
detachedButton.classList.toggle('active', layout.mode === 'detached');
|
detachedButton.classList.toggle('active', layout.mode === 'detached');
|
||||||
rightButton.classList.toggle('active', layout.mode === 'right');
|
rightButton.classList.toggle('active', layout.mode === 'right');
|
||||||
if (layout.mode === 'detached') {
|
if (layout.mode === 'detached') {
|
||||||
|
clearPageDock();
|
||||||
clampDetached();
|
clampDetached();
|
||||||
panel.style.left = `${layout.x}px`;
|
panel.style.left = `${layout.x}px`;
|
||||||
panel.style.top = `${layout.y}px`;
|
panel.style.top = `${layout.y}px`;
|
||||||
panel.style.width = `${layout.width}px`;
|
panel.style.width = `${layout.width}px`;
|
||||||
panel.style.height = `${layout.height}px`;
|
panel.style.height = `${layout.height}px`;
|
||||||
panel.style.resize = 'both';
|
panel.style.resize = 'both';
|
||||||
launcher.style.left = `${layout.x}px`;
|
|
||||||
launcher.style.right = 'auto';
|
|
||||||
launcher.style.top = `${layout.y}px`;
|
|
||||||
} else {
|
} else {
|
||||||
const viewport = viewportBounds();
|
const viewport = viewportBounds();
|
||||||
const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2)));
|
const dockWidth = Math.max(1, Math.min(size.width, viewport.width - 16));
|
||||||
const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64;
|
panel.style.top = '0';
|
||||||
panel.style.top = `${panelTop}px`;
|
panel.style.bottom = '0';
|
||||||
panel.style.width = `${Math.max(1, Math.min(size.width, viewport.width - gutter * 2))}px`;
|
panel.style.width = `${dockWidth}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.height = `${viewport.height}px`;
|
||||||
panel.style[layout.mode] = `${gutter}px`;
|
panel.style[layout.mode] = '0';
|
||||||
launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`;
|
applyPageDock(layout.mode, dockWidth);
|
||||||
launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto';
|
|
||||||
launcher.style.right = layout.mode === 'right' ? `${gutter}px` : 'auto';
|
|
||||||
}
|
}
|
||||||
globalThis.queueMicrotask(() => { applyingLayout = false; });
|
globalThis.queueMicrotask(() => { applyingLayout = false; });
|
||||||
}
|
}
|
||||||
@@ -369,9 +460,11 @@
|
|||||||
panel.classList.toggle('open', opened);
|
panel.classList.toggle('open', opened);
|
||||||
launcher.style.display = opened ? 'none' : '';
|
launcher.style.display = opened ? 'none' : '';
|
||||||
if (opened) {
|
if (opened) {
|
||||||
|
setUnreadCount(0);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
messages.scrollTop = messages.scrollHeight;
|
messages.scrollTop = messages.scrollHeight;
|
||||||
}
|
}
|
||||||
|
applyLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyContext(next) {
|
function applyContext(next) {
|
||||||
@@ -413,6 +506,7 @@
|
|||||||
const wrapper = element('article', 'message');
|
const wrapper = element('article', 'message');
|
||||||
const meta = element('div', 'meta');
|
const meta = element('div', 'meta');
|
||||||
const own = message.senderId === context.peerId;
|
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 sender = element('span', 'sender', own ? (strings().you || '') : (message.username || message.senderId || ''));
|
||||||
const time = element('time', 'time');
|
const time = element('time', 'time');
|
||||||
const date = new Date(message.timestamp);
|
const date = new Date(message.timestamp);
|
||||||
@@ -431,8 +525,44 @@
|
|||||||
messages.scrollTop = messages.scrollHeight;
|
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() {
|
function clearMessages() {
|
||||||
messages.replaceChildren(empty);
|
messages.replaceChildren(empty);
|
||||||
|
setUnreadCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetComposer() {
|
function resetComposer() {
|
||||||
@@ -445,7 +575,48 @@
|
|||||||
sendButton.disabled = false;
|
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));
|
closeButton.addEventListener('click', () => setOpened(false));
|
||||||
leftButton.addEventListener('click', () => setMode('left'));
|
leftButton.addEventListener('click', () => setMode('left'));
|
||||||
detachedButton.addEventListener('click', () => setMode('detached'));
|
detachedButton.addEventListener('click', () => setMode('detached'));
|
||||||
@@ -457,12 +628,18 @@
|
|||||||
count.style.color = length > 500 ? 'var(--danger)' : '';
|
count.style.color = length > 500 ? 'var(--danger)' : '';
|
||||||
status.textContent = length > 500 ? (strings().tooLong || '') : '';
|
status.textContent = length > 500 ? (strings().tooLong || '') : '';
|
||||||
});
|
});
|
||||||
textarea.addEventListener('keydown', event => {
|
const stopPageKeyboardShortcut = event => {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
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();
|
event.preventDefault();
|
||||||
if (!sending) composer.requestSubmit();
|
if (!sending) composer.requestSubmit();
|
||||||
}
|
}
|
||||||
});
|
event.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
for (const eventName of ['keydown', 'keyup', 'keypress']) {
|
||||||
|
window.addEventListener(eventName, stopPageKeyboardShortcut, true);
|
||||||
|
}
|
||||||
composer.addEventListener('submit', async event => {
|
composer.addEventListener('submit', async event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (sending || !context?.enabled) return;
|
if (sending || !context?.enabled) return;
|
||||||
@@ -535,10 +712,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
|
clampLauncher();
|
||||||
|
applyLayout();
|
||||||
|
saveLayout();
|
||||||
if (layout.mode === 'detached') {
|
if (layout.mode === 'detached') {
|
||||||
clampDetached();
|
clampDetached();
|
||||||
applyLayout();
|
|
||||||
saveLayout();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +743,7 @@
|
|||||||
|
|
||||||
function handleRuntime(message) {
|
function handleRuntime(message) {
|
||||||
if (message?.type === 'CHAT_MESSAGE') appendMessage(message.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_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh();
|
||||||
if (message?.type === 'CHAT_RESET') {
|
if (message?.type === 'CHAT_RESET') {
|
||||||
clearMessages();
|
clearMessages();
|
||||||
@@ -581,6 +760,11 @@
|
|||||||
sendGeneration++;
|
sendGeneration++;
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
clearPageDock();
|
||||||
|
pageDockStyle.remove();
|
||||||
|
for (const eventName of ['keydown', 'keyup', 'keypress']) {
|
||||||
|
window.removeEventListener(eventName, stopPageKeyboardShortcut, true);
|
||||||
|
}
|
||||||
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
|
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
systemTheme.removeEventListener('change', handleSystemTheme);
|
systemTheme.removeEventListener('change', handleSystemTheme);
|
||||||
|
|||||||
@@ -1632,6 +1632,13 @@
|
|||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
<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>
|
<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">
|
<span class="settings-select">
|
||||||
|
|||||||
+9
-2
@@ -69,6 +69,7 @@ const elements = {
|
|||||||
pauseBtn: document.getElementById('pauseBtn'),
|
pauseBtn: document.getElementById('pauseBtn'),
|
||||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||||
chatEnabled: document.getElementById('chatEnabled'),
|
chatEnabled: document.getElementById('chatEnabled'),
|
||||||
|
chatNotifications: document.getElementById('chatNotifications'),
|
||||||
chatPosition: document.getElementById('chatPosition'),
|
chatPosition: document.getElementById('chatPosition'),
|
||||||
chatSize: document.getElementById('chatSize'),
|
chatSize: document.getElementById('chatSize'),
|
||||||
chatStartMode: document.getElementById('chatStartMode'),
|
chatStartMode: document.getElementById('chatStartMode'),
|
||||||
@@ -341,7 +342,7 @@ function setRoomRefreshCooldown() {
|
|||||||
async function init() {
|
async function init() {
|
||||||
// Local-only by design — settings and room credentials never come from
|
// Local-only by design — settings and room credentials never come from
|
||||||
// storage.sync (only onboardingComplete + dismissedHints live there).
|
// 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;
|
let activeLang = localData.locale;
|
||||||
if (!activeLang) {
|
if (!activeLang) {
|
||||||
@@ -373,6 +374,7 @@ async function init() {
|
|||||||
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
||||||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
||||||
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
|
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.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.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.chatStartMode) elements.chatStartMode.value = localData.chatStartMode === 'open' ? 'open' : 'bubble';
|
||||||
@@ -1467,7 +1469,7 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
|||||||
|
|
||||||
function syncChatSettingsState() {
|
function syncChatSettingsState() {
|
||||||
const enabled = elements.chatEnabled?.checked === true;
|
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;
|
if (control) control.disabled = !enabled;
|
||||||
}
|
}
|
||||||
document.querySelectorAll('[data-chat-setting]').forEach(row => {
|
document.querySelectorAll('[data-chat-setting]').forEach(row => {
|
||||||
@@ -1482,6 +1484,11 @@ if (elements.chatEnabled) {
|
|||||||
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
|
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) {
|
if (elements.chatPosition) {
|
||||||
elements.chatPosition.addEventListener('change', () => {
|
elements.chatPosition.addEventListener('change', () => {
|
||||||
const chatPosition = ['left', 'detached'].includes(elements.chatPosition.value) ? elements.chatPosition.value : 'right';
|
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 chatSection = detailsSection('data-i18n="CHAT_TITLE"');
|
||||||
const syncSection = detailsSection('data-i18n="LABEL_SETTINGS_GROUP_SYNC"');
|
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.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');
|
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}`);
|
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.match(popupJs, new RegExp(`chrome\\.storage\\.local\\.set\\(\\{ ${key}`), `popup persists ${key}`);
|
||||||
assert.ok(backgroundJs.includes(`'${key}'`), `legacy sync purge includes ${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.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, /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, /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');
|
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