Add multiple-session support: run several independent encrypted chats at once
CodeQL Analysis / Analyze CodeQL (push) Has been cancelled
Deploy Application / deploy (push) Has been cancelled
Mirror to Codeberg / mirror (push) Has been cancelled
Mirror to PrivacyGuides / mirror (push) Has been cancelled

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.
This commit is contained in:
lockbitchat
2026-06-26 00:00:13 -04:00
parent db5d6e481d
commit 0e3e3a2974
16 changed files with 2828 additions and 924 deletions
+887 -362
View File
File diff suppressed because it is too large Load Diff
+336
View File
@@ -0,0 +1,336 @@
// Sessions registry for SecureBit.chat multi-session support.
//
// Pure, framework-free reducer + helpers. The root React component drives it via
// React.useReducer; non-serializable per-session objects (the EnhancedSecureWebRTCManager
// instance, its NotificationIntegration, offline queues, QR-animation timers) live OUTSIDE
// this state in ref-held Maps keyed by sessionId — never in here, never shared between
// sessions. Every reducer case returns fresh objects for the touched session only, so a
// change to one session can never mutate another (full isolation).
//
// sessionId is LOCAL ONLY (crypto.randomUUID). It is never sent to the peer as identity.
export const SESSION_ACTIONS = Object.freeze({
CREATE_SESSION: 'CREATE_SESSION',
REMOVE_SESSION: 'REMOVE_SESSION',
SET_ACTIVE: 'SET_ACTIVE',
SET_STATUS: 'SET_STATUS',
SET_FINGERPRINT: 'SET_FINGERPRINT',
SET_VERIFICATION: 'SET_VERIFICATION',
SET_SAS: 'SET_SAS',
ADD_MESSAGE: 'ADD_MESSAGE',
SET_MESSAGES: 'SET_MESSAGES',
UPDATE_MESSAGE_STATUS: 'UPDATE_MESSAGE_STATUS',
DELETE_MESSAGE: 'DELETE_MESSAGE',
EXPIRE_MESSAGE: 'EXPIRE_MESSAGE',
INCREMENT_UNREAD: 'INCREMENT_UNREAD',
CLEAR_UNREAD: 'CLEAR_UNREAD',
SET_PENDING_FILES: 'SET_PENDING_FILES',
PATCH_SETUP: 'PATCH_SETUP',
RENAME: 'RENAME',
SET_PEER_PRESENCE: 'SET_PEER_PRESENCE'
});
// Availability presence the PEER advertises to us (sent E2E over the data channel, never
// stored on a server). 'invisible' is sent on the wire as 'offline' so peers can't tell.
export const PRESENCE_DOT = { available: '#3ecf8e', away: '#e3b341', busy: '#e5727a', offline: '#6b6b73' };
export const PRESENCE_WORD = { available: 'Available', away: 'Away', busy: 'Busy', offline: 'Offline' };
// The statuses the local user can pick for themselves (design: Set your status).
export const MY_STATUS_OPTIONS = [
{ key: 'available', word: 'Available', desc: 'Online and reachable', dot: '#3ecf8e' },
{ key: 'away', word: 'Away', desc: 'Idle · stepped away', dot: '#e3b341' },
{ key: 'busy', word: 'Busy', desc: 'Do not disturb', dot: '#e5727a' },
{ key: 'invisible', word: 'Invisible', desc: 'Appear offline to peers', dot: '#6b6b73' }
];
// Short, human-friendly default label derived from the local sessionId. Never the peer's
// identity — just something stable to show before the SAS-derived label is available.
export function shortLabelFromId(id) {
const hex = String(id || '').replace(/[^a-z0-9]/gi, '');
return 'Chat ' + (hex.slice(0, 4) || '0000').toUpperCase();
}
// Two-letter monogram for the avatar tile (mirrors the design's `mono()` helper).
export function monoInitials(label) {
const words = String(label || '').trim().split(/\s+/).filter(Boolean);
const a = words[0]?.[0] || '';
const b = words[1]?.[0] || words[0]?.[1] || '';
return (a + b).toUpperCase() || '··';
}
// Status → dot colour (mirrors the design's DOT map).
export function statusDot(status) {
switch (status) {
case 'connected':
case 'verified':
return '#3ecf8e';
case 'connecting':
case 'verifying':
case 'new':
return '#e3b341';
default:
return '#e5727a'; // disconnected / peer_disconnected / lost
}
}
// Status → header sub-text (mirrors the design's SUB map).
export function statusSub(status) {
switch (status) {
case 'connected':
case 'verified':
return 'P2P · connected';
case 'verifying':
return 'Verifying…';
case 'connecting':
case 'new':
return 'Connecting…';
case 'peer_disconnected':
return 'Peer disconnected';
default:
return 'Disconnected';
}
}
function emptySetup() {
return {
offerData: '',
answerData: '',
offerInput: '',
answerInput: '',
showOfferStep: false,
showAnswerStep: false,
showVerification: false,
showQRCode: false,
qrCodeUrl: '',
isGeneratingKeys: false,
qrFramesTotal: 0,
qrFrameIndex: 0,
qrManualMode: false
};
}
export function createSessionEntry(opts = {}) {
const id = opts.id || (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random());
return {
id,
peerLabel: opts.peerLabel || shortLabelFromId(id),
labelIsCustom: false, // becomes true once the user renames; blocks SAS auto-relabel
createdAt: opts.createdAt || Date.now(),
role: opts.role || 'offer', // 'offer' | 'answer'
status: opts.status || 'new',
keyFingerprint: '',
verificationCode: '',
sas: { localConfirmed: false, remoteConfirmed: false, bothConfirmed: false, isVerified: false },
messages: [],
unreadCount: 0,
pendingIncomingFiles: [],
peerPresence: null, // peer's advertised availability ('available'|'away'|'busy'|'offline'); null = unknown
setup: emptySetup()
};
}
export function createInitialState() {
return { sessions: {}, order: [], activeSessionId: null };
}
// Apply a partial patch to one session, returning a new state with only that session's
// object replaced. Other sessions keep their identity (referential isolation).
function patchSession(state, id, patch) {
const session = state.sessions[id];
if (!session) return state;
return {
...state,
sessions: { ...state.sessions, [id]: { ...session, ...patch } }
};
}
export function sessionsReducer(state, action) {
const A = SESSION_ACTIONS;
switch (action.type) {
case A.CREATE_SESSION: {
const entry = action.entry || createSessionEntry(action);
if (state.sessions[entry.id]) return state;
return {
sessions: { ...state.sessions, [entry.id]: entry },
order: [...state.order, entry.id],
activeSessionId: action.activate === false ? state.activeSessionId : entry.id
};
}
case A.REMOVE_SESSION: {
const { id } = action;
if (!state.sessions[id]) return state;
const sessions = { ...state.sessions };
delete sessions[id];
const order = state.order.filter((x) => x !== id);
let activeSessionId = state.activeSessionId;
if (activeSessionId === id) {
// Re-point to the previous sibling in display order, else the first remaining.
const removedIdx = state.order.indexOf(id);
activeSessionId = order[Math.max(0, removedIdx - 1)] || order[0] || null;
}
return { sessions, order, activeSessionId };
}
case A.SET_ACTIVE: {
if (!state.sessions[action.id]) return state;
if (state.activeSessionId === action.id) return state;
return { ...state, activeSessionId: action.id };
}
case A.SET_STATUS: {
const session = state.sessions[action.id];
if (!session || session.status === action.status) return state; // no-op if unchanged
return patchSession(state, action.id, { status: action.status });
}
case A.SET_FINGERPRINT:
return patchSession(state, action.id, { keyFingerprint: action.fingerprint });
case A.SET_VERIFICATION:
return patchSession(state, action.id, { verificationCode: action.code });
case A.SET_SAS: {
const session = state.sessions[action.id];
if (!session) return state;
return patchSession(state, action.id, { sas: { ...session.sas, ...action.sas } });
}
case A.ADD_MESSAGE: {
const session = state.sessions[action.id];
if (!session) return state;
return patchSession(state, action.id, { messages: [...session.messages, action.message] });
}
case A.SET_MESSAGES: {
const session = state.sessions[action.id];
if (!session) return state;
const next = typeof action.updater === 'function'
? action.updater(session.messages)
: action.messages;
return patchSession(state, action.id, { messages: Array.isArray(next) ? next : [] });
}
case A.UPDATE_MESSAGE_STATUS: {
const session = state.sessions[action.id];
if (!session) return state;
let changed = false;
const messages = session.messages.map((m) => {
if (String(m.mid) === String(action.mid) && m.status !== action.status) {
changed = true;
return { ...m, status: action.status };
}
return m;
});
return changed ? patchSession(state, action.id, { messages }) : state;
}
case A.DELETE_MESSAGE: {
const session = state.sessions[action.id];
if (!session) return state;
const messages = session.messages.filter((m) => String(m.mid) !== String(action.mid));
if (messages.length === session.messages.length) return state;
return patchSession(state, action.id, { messages });
}
case A.EXPIRE_MESSAGE: {
const session = state.sessions[action.id];
if (!session) return state;
let changed = false;
const messages = session.messages.map((m) => {
if (String(m.id) === String(action.messageId) && !m.expired) {
changed = true;
return { ...m, expired: true, message: '', expiresAt: undefined };
}
return m;
});
return changed ? patchSession(state, action.id, { messages }) : state;
}
case A.INCREMENT_UNREAD: {
const session = state.sessions[action.id];
if (!session) return state;
return patchSession(state, action.id, { unreadCount: session.unreadCount + 1 });
}
case A.CLEAR_UNREAD: {
const session = state.sessions[action.id];
if (!session || session.unreadCount === 0) return state;
return patchSession(state, action.id, { unreadCount: 0 });
}
case A.SET_PENDING_FILES: {
const session = state.sessions[action.id];
if (!session) return state;
const next = typeof action.updater === 'function'
? action.updater(session.pendingIncomingFiles)
: action.files;
return patchSession(state, action.id, { pendingIncomingFiles: Array.isArray(next) ? next : [] });
}
case A.PATCH_SETUP: {
const session = state.sessions[action.id];
if (!session) return state;
return patchSession(state, action.id, { setup: { ...session.setup, ...action.patch } });
}
case A.RENAME: {
const session = state.sessions[action.id];
if (!session) return state;
const label = String(action.label || '').trim() || session.peerLabel;
return patchSession(state, action.id, { peerLabel: label, labelIsCustom: true });
}
case A.SET_PEER_PRESENCE: {
const session = state.sessions[action.id];
if (!session || session.peerPresence === action.presence) return state;
return patchSession(state, action.id, { peerPresence: action.presence });
}
default:
return state;
}
}
// Decorate a session into the shape the sidebar/header rendering consumes (avatar monogram,
// status dot, sub-text, last-message preview, unread badge). Pure derivation — no state.
export function decorateSession(session, activeSessionId) {
const lastMessage = [...session.messages].reverse().find((m) => !m.expired && typeof m.message === 'string' && m.message.trim());
const s = session.status;
const isUp = s === 'connected' || s === 'verified';
const isPending = s === 'connecting' || s === 'verifying' || s === 'new';
// Avatar dot + sub-text: while a session is up, reflect the PEER's advertised presence;
// otherwise reflect the connection state (amber = connecting, red = dropped).
let dot, headerSub;
if (isPending) {
dot = '#e3b341';
headerSub = statusSub(s);
} else if (isUp) {
dot = session.peerPresence ? (PRESENCE_DOT[session.peerPresence] || '#6b6b73') : '#3ecf8e';
headerSub = session.peerPresence ? (PRESENCE_WORD[session.peerPresence] || 'Online') : 'P2P · connected';
} else {
dot = '#e5727a';
headerSub = statusSub(s);
}
const preview = lastMessage ? lastMessage.message : headerSub;
return {
id: session.id,
name: session.peerLabel,
mono: monoInitials(session.peerLabel),
dot,
headerSub,
status: session.status,
peerPresence: session.peerPresence,
preview,
unread: session.unreadCount > 0 ? (session.unreadCount > 99 ? '99+' : String(session.unreadCount)) : null,
verified: !!session.sas.isVerified,
active: session.id === activeSessionId,
inactive: session.id !== activeSessionId
};
}
export function decorateSessions(state) {
return state.order
.map((id) => state.sessions[id])
.filter(Boolean)
.map((s) => decorateSession(s, state.activeSessionId));
}
+47 -27
View File
@@ -155,8 +155,20 @@ class UpdateManager {
}
const meta = await response.json();
// The service worker returns a fallback meta { error: 'Network unavailable',
// version: <default> } when it can't reach the network. That default ("v4.7.56")
// must NOT be treated as a real server version — otherwise a transient drop pops a
// bogus "Update available → v4.7.56". Ignore any error-tagged response.
if (meta && meta.error) {
if (this.options.debug) {
console.warn('⚠️ meta.json came from offline fallback — skipping update check:', meta.error);
}
return { hasUpdate: false, error: meta.error };
}
this.serverVersion = meta.version || meta.buildVersion || null;
if (!this.serverVersion) {
throw new Error('Version not found in meta.json');
}
@@ -249,47 +261,55 @@ class UpdateManager {
}
this.isUpdating = true;
// Step logging (always on) so we can see exactly where an update stalls.
const log = (m) => { try { console.log('🔧 [update] ' + m); } catch (_) {} };
// Run a cleanup step but never let it block the reload: it still executes fully, we just
// stop AWAITING it past `ms`. This keeps all the security cleanup (SW caches, SW
// unregister, storage wipe) while guaranteeing the page reloads.
const capped = (label, promise, ms) => Promise.race([
Promise.resolve(promise).then(() => log(label + ' done')).catch((e) => log(label + ' error: ' + (e && e.message))),
new Promise((resolve) => setTimeout(() => { log(label + ' still running after ' + ms + 'ms — continuing'); resolve(); }, ms))
]);
const navigate = () => {
log('navigating to new version…');
try { window.location.href = `${window.location.pathname}?v=${Date.now()}&_update=true`; }
catch (_) { try { window.location.reload(); } catch (__) {} }
};
try {
if (this.options.debug) {
console.log('🚀 Starting force update...');
}
log('start (online=' + (typeof navigator !== 'undefined' ? navigator.onLine : 'n/a') + ')');
// Step 1: Preserve critical data
const preservedData = this.preserveCriticalData();
// Step 2: Clear Service Worker caches
await this.clearServiceWorkerCaches();
// Step 3: Unregister Service Workers
await this.unregisterServiceWorkers();
// Step 2: Clear Service Worker caches (time-boxed, still runs fully)
await capped('clearServiceWorkerCaches', this.clearServiceWorkerCaches(), 3000);
// Step 3: Unregister Service Workers (time-boxed, still runs fully)
await capped('unregisterServiceWorkers', this.unregisterServiceWorkers(), 3000);
// Step 4: Clear browser cache (localStorage, sessionStorage)
this.clearBrowserCaches();
log('browser caches cleared');
// Step 5: Update version
if (this.serverVersion) {
this.setLocalVersion(this.serverVersion);
}
// Step 6: Restore critical data
this.restoreCriticalData(preservedData);
// Step 7: Force reload with cache-busting
if (this.options.debug) {
console.log('🔄 Reloading page with new version...');
}
// Small delay to complete operations
await new Promise(resolve => setTimeout(resolve, 500));
// Reload with full cache bypass
window.location.href = `${window.location.pathname}?v=${Date.now()}&_update=true`;
await new Promise(resolve => setTimeout(resolve, 300));
navigate();
} catch (error) {
this.handleError('Force update failed', error);
this.isUpdating = false;
throw error;
// The new build loads from the network regardless — never leave the user stuck.
navigate();
}
}