0e3e3a2974
Each conversation now runs its own WebRTC session with separate keys and SAS verification, so chats never mix. Adds a side panel to switch between open chats with unread badges, a New chat action that leaves existing chats connected, per-chat local labels stored only on this device, and an availability status (Available, Away, Busy, Invisible) shared end-to-end with connected peers. Also includes vendored Prism syntax highlighting, more reliable PWA update handling, and offline send queueing fixes. Version 4.10.0.
140 lines
6.2 KiB
JavaScript
140 lines
6.2 KiB
JavaScript
// Verifies the multi-session reducer keeps sessions fully isolated: a change to one
|
|
// session never mutates another, unread only grows for non-active received traffic, and
|
|
// removing a session re-points the active pointer without disturbing siblings.
|
|
import assert from 'node:assert/strict';
|
|
|
|
const {
|
|
sessionsReducer,
|
|
createInitialState,
|
|
createSessionEntry,
|
|
SESSION_ACTIONS: A,
|
|
decorateSession,
|
|
monoInitials,
|
|
statusDot
|
|
} = await import('../src/state/sessionsStore.js');
|
|
|
|
function withTwoSessions() {
|
|
let state = createInitialState();
|
|
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'a', peerLabel: 'work laptop' }) });
|
|
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'b', peerLabel: 'atlas repo' }) });
|
|
return state;
|
|
}
|
|
|
|
// CREATE_SESSION activates the new session and preserves order.
|
|
{
|
|
const state = withTwoSessions();
|
|
assert.deepEqual(state.order, ['a', 'b']);
|
|
assert.equal(state.activeSessionId, 'b', 'newest session becomes active');
|
|
assert.equal(Object.keys(state.sessions).length, 2);
|
|
}
|
|
|
|
// Isolation: mutating session B leaves session A's object referentially untouched.
|
|
{
|
|
const before = withTwoSessions();
|
|
const aRef = before.sessions.a;
|
|
const after = sessionsReducer(before, { type: A.ADD_MESSAGE, id: 'b', message: { id: 1, message: 'hi', type: 'sent' } });
|
|
assert.equal(after.sessions.a, aRef, 'session A object must be the same reference after editing B');
|
|
assert.equal(after.sessions.b.messages.length, 1);
|
|
assert.equal(after.sessions.a.messages.length, 0, 'A transcript untouched');
|
|
// And the original state object was not mutated in place.
|
|
assert.equal(before.sessions.b.messages.length, 0, 'reducer is immutable');
|
|
}
|
|
|
|
// SET_STATUS / SET_FINGERPRINT / SET_SAS are scoped to one session.
|
|
{
|
|
let state = withTwoSessions();
|
|
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'verified' });
|
|
state = sessionsReducer(state, { type: A.SET_SAS, id: 'a', sas: { isVerified: true, bothConfirmed: true } });
|
|
state = sessionsReducer(state, { type: A.SET_FINGERPRINT, id: 'a', fingerprint: 'AB:CD' });
|
|
assert.equal(state.sessions.a.status, 'verified');
|
|
assert.equal(state.sessions.a.sas.isVerified, true);
|
|
assert.equal(state.sessions.a.keyFingerprint, 'AB:CD');
|
|
assert.equal(state.sessions.b.status, 'new', 'sibling status untouched');
|
|
assert.equal(state.sessions.b.sas.isVerified, false, 'sibling SAS untouched');
|
|
assert.equal(state.sessions.b.keyFingerprint, '', 'sibling fingerprint untouched');
|
|
}
|
|
|
|
// UPDATE_MESSAGE_STATUS and DELETE_MESSAGE only touch the named session/message.
|
|
{
|
|
let state = withTwoSessions();
|
|
state = sessionsReducer(state, { type: A.ADD_MESSAGE, id: 'a', message: { id: 1, mid: 'm1', message: 'x', type: 'sent', status: 'sending' } });
|
|
state = sessionsReducer(state, { type: A.UPDATE_MESSAGE_STATUS, id: 'a', mid: 'm1', status: 'delivered' });
|
|
assert.equal(state.sessions.a.messages[0].status, 'delivered');
|
|
state = sessionsReducer(state, { type: A.DELETE_MESSAGE, id: 'a', mid: 'm1' });
|
|
assert.equal(state.sessions.a.messages.length, 0);
|
|
assert.equal(state.sessions.b.messages.length, 0);
|
|
}
|
|
|
|
// Unread bookkeeping.
|
|
{
|
|
let state = withTwoSessions(); // active = b
|
|
state = sessionsReducer(state, { type: A.INCREMENT_UNREAD, id: 'a' });
|
|
state = sessionsReducer(state, { type: A.INCREMENT_UNREAD, id: 'a' });
|
|
assert.equal(state.sessions.a.unreadCount, 2);
|
|
assert.equal(state.sessions.b.unreadCount, 0);
|
|
state = sessionsReducer(state, { type: A.SET_ACTIVE, id: 'a' });
|
|
state = sessionsReducer(state, { type: A.CLEAR_UNREAD, id: 'a' });
|
|
assert.equal(state.sessions.a.unreadCount, 0);
|
|
assert.equal(state.activeSessionId, 'a');
|
|
}
|
|
|
|
// PATCH_SETUP merges, scoped per session.
|
|
{
|
|
let state = withTwoSessions();
|
|
state = sessionsReducer(state, { type: A.PATCH_SETUP, id: 'a', patch: { offerData: 'OFFER', showOfferStep: true } });
|
|
assert.equal(state.sessions.a.setup.offerData, 'OFFER');
|
|
assert.equal(state.sessions.a.setup.showOfferStep, true);
|
|
assert.equal(state.sessions.a.setup.answerData, '', 'untouched setup field keeps default');
|
|
assert.equal(state.sessions.b.setup.offerData, '', 'sibling setup untouched');
|
|
}
|
|
|
|
// RENAME marks the label custom.
|
|
{
|
|
let state = withTwoSessions();
|
|
state = sessionsReducer(state, { type: A.RENAME, id: 'a', label: 'Alice' });
|
|
assert.equal(state.sessions.a.peerLabel, 'Alice');
|
|
assert.equal(state.sessions.a.labelIsCustom, true);
|
|
assert.equal(state.sessions.b.labelIsCustom, false);
|
|
}
|
|
|
|
// REMOVE_SESSION re-points active to the previous sibling and leaves the rest intact.
|
|
{
|
|
let state = withTwoSessions(); // order [a,b], active b
|
|
const bRef = state.sessions.b;
|
|
state = sessionsReducer(state, { type: A.SET_ACTIVE, id: 'a' });
|
|
state = sessionsReducer(state, { type: A.REMOVE_SESSION, id: 'a' });
|
|
assert.equal(state.sessions.a, undefined, 'a removed');
|
|
assert.equal(state.sessions.b, bRef, 'sibling b object untouched');
|
|
assert.deepEqual(state.order, ['b']);
|
|
assert.equal(state.activeSessionId, 'b', 'active re-pointed to remaining session');
|
|
}
|
|
|
|
// REMOVE_SESSION on the last session leaves no active.
|
|
{
|
|
let state = createInitialState();
|
|
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'solo' }) });
|
|
state = sessionsReducer(state, { type: A.REMOVE_SESSION, id: 'solo' });
|
|
assert.equal(state.activeSessionId, null);
|
|
assert.deepEqual(state.order, []);
|
|
}
|
|
|
|
// Decorators mirror the design helpers.
|
|
{
|
|
assert.equal(monoInitials('work laptop'), 'WL');
|
|
assert.equal(monoInitials('atlas'), 'AT');
|
|
assert.equal(statusDot('verified'), '#3ecf8e');
|
|
assert.equal(statusDot('connecting'), '#e3b341');
|
|
assert.equal(statusDot('disconnected'), '#e5727a');
|
|
|
|
const entry = createSessionEntry({ id: 'a', peerLabel: 'work laptop' });
|
|
entry.unreadCount = 3;
|
|
entry.status = 'connecting';
|
|
const d = decorateSession(entry, 'b');
|
|
assert.equal(d.mono, 'WL');
|
|
assert.equal(d.unread, '3');
|
|
assert.equal(d.active, false);
|
|
assert.equal(d.inactive, true);
|
|
}
|
|
|
|
console.log('sessions-reducer.test.mjs: all assertions passed');
|