Skip to content

Commit 8050748

Browse files
committed
fix(extension): harden chat and target tab lifecycle
1 parent 0b6ae80 commit 8050748

26 files changed

Lines changed: 776 additions & 226 deletions

docs/CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ All notable changes to the KoalaSync browser extension and relay server.
44

55
---
66

7-
## Unreleased
7+
## [v3.0.2] — 2026-07-31
88

99
### Added
1010
- **Extension: Quick reactions** — Adds six encrypted one-click reactions with a local choice between chat-only display and bounded falling reactions over the video.
@@ -13,6 +13,11 @@ All notable changes to the KoalaSync browser extension and relay server.
1313
### Changed
1414
- **Extension: Real dock behavior** — Anchors the collapsed Koala chat launcher to the selected side, reserves a page column in normal and fullscreen layouts, and clips viewport-bound page layers away from the open dock; detached mode remains freely movable.
1515
- **Extension: Koala chat launcher** — Replaces the generic speech-bubble emoji with the KoalaSync extension icon plus a chat marker.
16+
- **Extension: Reliable long chat sends** — Accepts up to 5000 Unicode code points, splits them into at most ten compatible 500-codepoint messages, waits for each relay echo before clearing text, and suppresses chat notifications while the selected video tab is focused.
17+
18+
### Fixed
19+
- **Extension: Strict target-tab lifecycle** — Fully deactivates playback, audio, heartbeat, observer, seek-bridge, and chat code before changing targets, cleans up superseded injections, and limits website-bridge status delivery to the KoalaSync site instead of broadcasting to every open tab.
20+
- **Extension: Tab switching and page restore** — Keeps ordinary tab changes idle and injection-free, makes selecting the same target idempotent, blocks delayed callbacks after teardown, and restores the existing target cleanly after Firefox back/forward-cache suspension.
1621

1722
## [v3.0.1] — 2026-07-26
1823

docs/CHAT.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ the stamped value as AAD.
4949

5050
## Client policy
5151

52-
- Maximum plaintext length: 500 Unicode code points.
52+
- Maximum plaintext length per relay message: 500 Unicode code points.
53+
- The composer accepts up to 5000 Unicode code points and sends them sequentially
54+
as at most ten independently encrypted `chat-v1` messages.
5355
- Decrypted text is untrusted. Escape HTML before applying the supported limited
5456
Markdown formatting.
5557
- Quick reactions use the same encrypted message path as text. They remain readable
@@ -84,6 +86,10 @@ the stamped value as AAD.
8486
- Chat display is a local option and defaults to off. Enabling or disabling it never
8587
deletes the room chat secret, so it can be enabled later without creating a room.
8688
- Without the relay `chat` capability, no chat control is shown.
89+
- System notifications are suppressed while the selected video tab and its browser
90+
window are focused.
91+
- A send succeeds only after the relay echoes the authenticated ciphertext back to
92+
the sender. Unconfirmed and remaining split messages stay in the composer.
8793

8894
## Mixed-version rollout
8995

extension/background.js

Lines changed: 132 additions & 50 deletions
Large diffs are not rendered by default.

extension/chat-format.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,41 @@
2929
return tokens;
3030
}
3131

32-
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText });
32+
function splitChatText(value, maxCodePoints = 500, maxChunks = 10) {
33+
if (!Number.isInteger(maxCodePoints) || maxCodePoints < 1 || !Number.isInteger(maxChunks) || maxChunks < 1) {
34+
throw new TypeError('Chat chunk limits must be positive integers');
35+
}
36+
let remaining = [...String(value ?? '').trim()];
37+
if (remaining.length === 0) return [];
38+
if (remaining.length > maxCodePoints * maxChunks) {
39+
throw new RangeError('Chat composer text exceeds the chunk limit');
40+
}
41+
42+
const chunks = [];
43+
while (remaining.length > 0) {
44+
if (remaining.length <= maxCodePoints) {
45+
chunks.push(remaining.join('').trim());
46+
break;
47+
}
48+
49+
const remainingSlots = maxChunks - chunks.length;
50+
const minimumSplit = Math.max(1, remaining.length - (remainingSlots - 1) * maxCodePoints);
51+
let splitAt = maxCodePoints;
52+
for (let index = maxCodePoints - 1; index >= minimumSplit; index--) {
53+
if (/\s/u.test(remaining[index])) {
54+
splitAt = index;
55+
break;
56+
}
57+
}
58+
59+
const chunk = remaining.slice(0, splitAt).join('').trim();
60+
remaining = remaining.slice(splitAt);
61+
while (remaining.length > 0 && /\s/u.test(remaining[0])) remaining.shift();
62+
if (chunk) chunks.push(chunk);
63+
}
64+
65+
return chunks;
66+
}
67+
68+
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText, splitChatText });
3369
})(globalThis);

extension/chat-format.test.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,25 @@ describe('chat formatting', () => {
1919
{ type: 'em', text: 'italic' }
2020
]);
2121
});
22+
23+
it('splits up to 5000 Unicode code points into at most ten ordered chat-v1 messages', () => {
24+
const split = globalThis.KoalaSyncChatFormat.splitChatText;
25+
expect(split('hello world', 500, 10)).toEqual(['hello world']);
26+
27+
const emojiChunks = split('😀'.repeat(1001), 500, 10);
28+
expect(emojiChunks.map(chunk => [...chunk].length)).toEqual([500, 500, 1]);
29+
30+
const maximumChunks = split('x'.repeat(5000), 500, 10);
31+
expect(maximumChunks).toHaveLength(10);
32+
expect(maximumChunks.every(chunk => [...chunk].length === 500)).toBe(true);
33+
expect(maximumChunks.join('')).toBe('x'.repeat(5000));
34+
35+
expect(() => split('x'.repeat(5001), 500, 10)).toThrow(RangeError);
36+
});
37+
38+
it('prefers whitespace boundaries without creating more chunks than the limit allows', () => {
39+
const split = globalThis.KoalaSyncChatFormat.splitChatText;
40+
const chunks = split(`${'a'.repeat(420)} ${'b'.repeat(179)}`, 500, 10);
41+
expect(chunks).toEqual(['a'.repeat(420), 'b'.repeat(179)]);
42+
});
2243
});

extension/chat-overlay-contract.test.mjs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
3+
import vm from 'node:vm';
34
import { fileURLToPath } from 'node:url';
45
import { describe, expect, it } from 'vitest';
56

@@ -34,6 +35,15 @@ const chatKeys = [
3435
];
3536

3637
describe('chat overlay contract', () => {
38+
it('skips SVG and other non-HTML documents before creating the overlay host', () => {
39+
const documentGuard = "document.documentElement?.namespaceURI !== 'http://www.w3.org/1999/xhtml'";
40+
expect(overlaySource).toContain(documentGuard);
41+
expect(overlaySource.indexOf(documentGuard)).toBeLessThan(overlaySource.indexOf("document.createElement('div')"));
42+
expect(() => vm.runInNewContext(overlaySource, {
43+
document: { documentElement: { namespaceURI: 'http://www.w3.org/2000/svg' } }
44+
})).not.toThrow();
45+
});
46+
3747
it('isolates the overlay and never renders markup as HTML', () => {
3848
expect(overlaySource).toContain("attachShadow({ mode: 'open' })");
3949
expect(overlaySource).not.toMatch(/\.innerHTML\s*=|insertAdjacentHTML|\.outerHTML\s*=/);
@@ -85,6 +95,7 @@ describe('chat overlay contract', () => {
8595
expect(overlaySource).toContain('margin-right: var(${PAGE_DOCK_WIDTH}) !important');
8696
expect(overlaySource).toContain('html[${PAGE_DOCK_ATTRIBUTE}] > body');
8797
expect(overlaySource).toContain('clip-path: inset(0) !important');
98+
expect(overlaySource).toContain('contain: layout paint !important');
8899
expect(overlaySource).toContain('> :not(#koalasync-chat-overlay-host)');
89100
expect(overlaySource).toContain('const target = document.fullscreenElement || document.documentElement');
90101
expect(overlaySource).toContain('pageDockTarget = target');
@@ -100,7 +111,9 @@ describe('chat overlay contract', () => {
100111
expect(overlaySource).toContain("unreadBadge.classList.toggle('visible', unreadCount > 0)");
101112
expect(backgroundSource).toMatch(/received\.senderId !== peerId[\s\S]*showChatNotification\([\s\S]*senderPeer\.username[\s\S]*received\.senderId/);
102113
expect(backgroundSource).toContain("chrome.notifications.create(`chat_${Date.now()}`");
103-
expect(backgroundSource).toContain("if (settings.chatNotifications === false) return");
114+
expect(backgroundSource).toContain('getTabForNotification(targetTabId)');
115+
expect(backgroundSource).toContain('getWindowForNotification(tab.windowId)');
116+
expect(backgroundSource).toContain('shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo })');
104117
});
105118

106119
it('renders timestamped room activity and keeps activity notifications opt-in', () => {
@@ -125,12 +138,24 @@ describe('chat overlay contract', () => {
125138
expect(overlaySource).toContain('launcherIcon.src = LAUNCHER_ICON_DATA_URL');
126139
expect(manifestSource).not.toContain('web_accessible_resources');
127140
expect(overlaySource).toContain("const QUICK_REACTIONS = Object.freeze(['❤️', '😂', '😮', '😢', '👏', '🔥'])");
128-
expect(overlaySource).toContain("messageRuntime({ type: 'CHAT_SEND', text })");
141+
expect(overlaySource).toContain("messageRuntime({ type: 'CHAT_SEND', text: chunk })");
129142
expect(overlaySource).toContain("chatReactionDisplay !== 'video'");
130143
expect(overlaySource).toContain('MAX_REACTION_PARTICLES - reactionLayer.childElementCount');
131144
expect(overlaySource).toContain('reducedMotion.matches');
132145
});
133146

147+
it('splits a 5000-codepoint composer value into at most ten acknowledged chat-v1 messages', () => {
148+
expect(overlaySource).toContain('const MAX_MESSAGE_CODE_POINTS = 500');
149+
expect(overlaySource).toContain('const MAX_CHAT_CHUNKS = 10');
150+
expect(overlaySource).toContain('MAX_MESSAGE_CODE_POINTS * MAX_CHAT_CHUNKS');
151+
expect(overlaySource).toContain('KoalaSyncChatFormat?.splitChatText(');
152+
expect(overlaySource).toMatch(/for \(const chunk of chunks\)[\s\S]*messageRuntime\(\{ type: 'CHAT_SEND', text: chunk \}\)/);
153+
expect(overlaySource).toContain("textarea.value = chunks.slice(completedChunks).join('\\n')");
154+
expect(backgroundSource).toContain('const echoPromise = chatEchoTracker.waitFor(ciphertext)');
155+
expect(backgroundSource).toContain('chatEchoTracker.acknowledge(received.ciphertext)');
156+
expect(backgroundSource).toContain("status: acknowledged ? 'ok' : 'unconfirmed'");
157+
});
158+
134159
it('excludes test-only modules from production extension artifacts', () => {
135160
expect(buildSource).toContain("/\\.test\\.[cm]?js$/u.test(item)");
136161
});
@@ -143,6 +168,9 @@ describe('chat overlay contract', () => {
143168
expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)');
144169
expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then');
145170
expect(backgroundSource).toContain("status: 'rate_limited'");
171+
expect(overlaySource).toContain('function setLocalStorage(values)');
172+
expect(overlaySource).toMatch(/chrome\.storage\.local\.get\([\s\S]*data => \{\s*if \(destroyed\) return;/);
173+
expect(overlaySource).toContain("if (destroyed || area !== 'local') return");
146174
});
147175

148176
it('keeps chat hidden by default without discarding the room chat key', () => {

0 commit comments

Comments
 (0)