Files
Haven/public/js/modules/app-platform.js
T

2054 lines
88 KiB
JavaScript

export default {
// ── Mark-Read Helper ──────────────────────────────────
// ═══════════════════════════════════════════════════════
_markRead(messageId) {
if (!this.currentChannel || !messageId) return;
// Capture the code at call-time, not when the timer fires. Without this
// a quick channel switch within the 500 ms debounce window would route
// the mark-read to whichever channel happens to be current when the
// timer fires, leaving the originally-viewed channel (commonly a DM the
// user just glanced at) stuck unread forever even after multiple visits.
const code = this.currentChannel;
// Debounce: don't spam the server
clearTimeout(this._markReadTimer);
this._markReadTimer = setTimeout(() => {
this.socket.emit('mark-read', { code, messageId });
// Mirror locally so badges immediately reflect the read state and
// don't bounce back to "1" on the next channels-list snapshot.
if (this.unreadCounts && this.unreadCounts[code]) {
this.unreadCounts[code] = 0;
try { this._updateBadge?.(code); } catch (err) { console.warn('[Unread] _updateBadge failed', err); }
try { this._updateDmSectionBadge?.(); } catch (err) { console.warn('[Unread] _updateDmSectionBadge failed', err); }
try { this._updateTabTitle?.(); } catch (err) { console.warn('[Unread] _updateTabTitle failed', err); }
try { this._updateDesktopBadge?.(); } catch (err) { console.warn('[Unread] _updateDesktopBadge failed', err); }
}
}, 500);
},
// ── Update Checker ─────────────────────────────────────
async _checkForUpdates() {
try {
// Get local version from the server
const localRes = await fetch('/api/version');
if (!localRes.ok) return;
const { version: localVersion } = await localRes.json();
// Check GitHub for latest release
const ghRes = await fetch('https://api.github.com/repos/ancsemi/Haven/releases/latest', {
headers: { Accept: 'application/vnd.github.v3+json' }
});
if (!ghRes.ok) return;
const release = await ghRes.json();
const remoteVersion = (release.tag_name || '').replace(/^v/, '');
if (!remoteVersion || !localVersion) return;
if (this._isNewerVersion(remoteVersion, localVersion)) {
// Cache the update info so visibility can be toggled without re-fetching
const zipAsset = (release.assets || []).find(a => a.name && a.name.endsWith('.zip'));
this._pendingUpdate = {
text: t('header.update_text', { version: remoteVersion }),
title: t('header.update_title', { remote: remoteVersion, local: localVersion }),
href: zipAsset ? zipAsset.browser_download_url : release.html_url
};
} else {
// Already current. This has to clear, because the check re-runs every 30
// minutes on a page that may have been open since before the update: the
// banner was only ever switched on, never off, so once it appeared it
// stayed until a full reload even after the server had been updated.
// Most visible on a machine that always runs the newest build, which
// would show "update available" for its own version. (#update-banner)
this._pendingUpdate = null;
}
this._applyUpdateBanner();
} catch (e) {
// Silently fail; update check is non-critical
}
// Re-check every 30 minutes
setTimeout(() => this._checkForUpdates(), 30 * 60 * 1000);
},
/**
* Show or hide the update banner based on cached update info and the
* update_banner_admin_only server setting.
*/
_applyUpdateBanner() {
const banner = document.getElementById('update-banner');
if (!banner) return;
// No update pending: hide it. This used to return early and leave whatever
// was on screen, which is how a stale banner survived once the server had
// caught up.
if (!this._pendingUpdate) { banner.style.display = 'none'; return; }
const adminOnly = this.serverSettings?.update_banner_admin_only === 'true';
const canSee = !adminOnly || this.user?.isAdmin;
if (canSee) {
banner.style.display = 'inline-flex';
banner.querySelector('.update-text').textContent = this._pendingUpdate.text;
banner.title = this._pendingUpdate.title;
banner.href = this._pendingUpdate.href;
} else {
banner.style.display = 'none';
}
},
/**
* Compare semver strings. Returns true if remote > local.
*/
_isNewerVersion(remote, local) {
const r = remote.split('.').map(Number);
const l = local.split('.').map(Number);
for (let i = 0; i < Math.max(r.length, l.length); i++) {
const rv = r[i] || 0;
const lv = l[i] || 0;
if (rv > lv) return true;
if (rv < lv) return false;
}
return false;
},
// ── Desktop App Banner (top bar only) ──────────────────
/** Wire the "Get the Desktop App" banner in the top bar. The promo modal
* itself is shown via the unified welcome-popup queue (see
* `_initWelcomePopups`); this function only handles the persistent banner. */
_initDesktopAppBanner() {
// Don't advertise the desktop app to someone already running it. This checks
// every signal the rest of the client uses rather than just two: the preload
// exposes window.havenDesktop late in a long file, so anything that throws
// above it leaves that undefined, and the runtime does not always carry
// "Electron" in the user agent. data-desktop-app is set independently by the
// preload, so any one of them surviving is enough to recognise the app.
const inDesktopApp = !!(
window.havenDesktop?.isDesktopApp ||
window.havenDesktop ||
navigator.userAgent.includes('Electron') ||
document.documentElement.hasAttribute('data-desktop-app')
);
if (inDesktopApp) {
// Hide rather than just bail: the banner may already be on screen from a
// check that ran before the desktop signals landed.
const b = document.getElementById('desktop-app-banner');
if (b) b.style.display = 'none';
return;
}
// Don't show on mobile / tablet, desktop app isn't relevant there
if (/Android|iPhone|iPad|iPod|Mobile|Tablet/i.test(navigator.userAgent)) return;
// ── Top-bar banner ──
const bannerDismissed = localStorage.getItem('haven_desktop_banner_dismissed');
if (!bannerDismissed) {
const banner = document.getElementById('desktop-app-banner');
if (banner) {
banner.style.display = 'inline-flex';
const dismissBtn = document.getElementById('desktop-app-dismiss');
if (dismissBtn) {
dismissBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
banner.style.display = 'none';
localStorage.setItem('haven_desktop_banner_dismissed', '1');
});
}
}
}
// ── Wire the promo modal's close paths. The welcome-popup queue handles
// auto-show + seen-tracking; here we just make sure the buttons inside
// the modal hide it. ──
const modal = document.getElementById('desktop-promo-modal');
if (!modal) return;
// Detect platform for the meta line
const meta = document.getElementById('desktop-promo-meta');
if (meta) {
const ua = navigator.userAgent.toLowerCase();
let platform = t('platform.desktop.desktop');
if (ua.includes('win')) platform = t('platform.desktop.windows_installer');
else if (ua.includes('linux')) platform = t('platform.desktop.linux_installer');
else if (ua.includes('mac')) platform = t('platform.desktop.macos_installer');
meta.textContent = `${platform} \u2022 v1.0.0`;
}
const laterBtn = document.getElementById('desktop-promo-later');
if (laterBtn) laterBtn.addEventListener('click', () => { modal.style.display = 'none'; });
const installBtn = document.getElementById('desktop-promo-install');
if (installBtn) installBtn.addEventListener('click', () => { modal.style.display = 'none'; });
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.style.display = 'none';
});
},
/** Wire the "Android Beta" banner in the top bar. The signup modal itself
* is shown via the unified welcome-popup queue (see `_initWelcomePopups`). */
_initAndroidBetaBanner() {
// ── Top-bar banner ──
// Gone for good once the person closes it, or ticks "Don't show this
// again" on the promo. The record lives with the account like the promo's
// own, with a localStorage copy for the moment before preferences arrive.
// Nothing wrote the permanent flag before, so the banner came back on
// every reload whatever was clicked (#5594).
const banner = document.getElementById('android-beta-banner');
if (banner && !banner.dataset.wired) {
banner.dataset.wired = '1';
banner.addEventListener('click', (e) => {
// Don't open modal if dismiss button was clicked
if (e.target.closest('.android-beta-dismiss')) return;
const modal = document.getElementById('android-beta-modal');
if (modal) modal.style.display = 'flex';
});
const dismissBtn = document.getElementById('android-beta-dismiss');
if (dismissBtn) {
dismissBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
try { localStorage.setItem('haven_ab_banner_nodisplay', '1'); } catch { /* storage unavailable */ }
if (this._userPrefs) this._userPrefs.android_banner_seen = 'true';
this.socket?.emit('set-preference', { key: 'android_banner_seen', value: 'true' });
this._syncAndroidBanner();
});
}
}
this._syncAndroidBanner();
// ── Wire the modal's own close buttons (Maybe Later, Submit, overlay
// click) to just hide the modal. The welcome-popup queue takes care of
// marking the entry as seen and advancing to the next popup via a
// MutationObserver on display style. ──
const modal = document.getElementById('android-beta-modal');
if (!modal) return;
const submitBtn = document.getElementById('android-beta-submit');
if (submitBtn) {
submitBtn.addEventListener('click', () => { modal.style.display = 'none'; });
}
const laterBtn = document.getElementById('android-beta-later');
if (laterBtn) {
laterBtn.addEventListener('click', () => { modal.style.display = 'none'; });
}
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.style.display = 'none';
});
},
/** Show or hide the top-bar Android banner from what is known right now:
* closed once (account record or its local copy), or the promo's "Don't
* show this again" ticked. Runs at start-up, when preferences arrive, and
* after either dismissal (#5594). Until the account's record is in, the
* banner stays down rather than flashing at someone who already closed it. */
_syncAndroidBanner() {
const banner = document.getElementById('android-beta-banner');
if (!banner) return;
let local = false;
try { local = !!localStorage.getItem('haven_ab_banner_nodisplay'); } catch { /* storage unavailable */ }
const prefs = this._userPrefs || {};
const gone = local || prefs.android_banner_seen === 'true' || prefs.promo_seen_android === 'true';
if (gone) { banner.style.display = 'none'; return; }
if (!this._userPrefs) return;
banner.style.display = 'inline-flex';
},
// ── Welcome Popup Queue (#5391 followup) ───────────────
/** Unified first-time-visit popup sequencer. Replaces the previous
* uncoordinated `setTimeout`-soup where each promo modal raced the others.
* Behavior:
* 1. Dismissal state is per-account, stored server-side in user_preferences
* (keys promo_seen_*), never in localStorage. A popup whose pref is set
* is skipped. State is fetched via get-preferences; the queue waits for
* it before showing anything.
* 2. Shows remaining popups one at a time, injecting a footer with
* "Next" + "Skip all" so users can click through or bail in one go.
* 3. Persistence is opt-in: a dismissal is written to the account ONLY when
* the user ticks that modal's "Don't show again" box. Any plain close
* (Next, Skip all, X, overlay click, Maybe Later, primary CTA) is
* session-only: the popup returns on the next login. */
_initWelcomePopups() {
// Run the queue at most once per page load.
if (this._welcomePopupsStarted) return;
// Dismissal state lives server-side in user_preferences (fetched via
// get-preferences). Wait for it to land before deciding what to show;
// otherwise a hardened browser (which wipes localStorage every session and
// so never had a client-side record anyway) would re-show a modal the user
// already told us to stop showing. No localStorage is read or written here.
if (!this._userPrefs) {
this.socket.once('preferences', () => this._initWelcomePopups());
return;
}
this._welcomePopupsStarted = true;
// One-time carry-over from the localStorage flags older builds used, so an
// account that already closed a promo or the recovery notice is not shown
// it again after upgrading. Runs once per browser; the account pref is the
// record from then on.
try {
if (!localStorage.getItem('haven_promo_prefs_migrated')) {
let seen = {};
try { seen = JSON.parse(localStorage.getItem('haven_welcome_seen_v1') || '{}') || {}; } catch { seen = {}; }
const legacy = {
promo_seen_desktop: seen.desktop_app_promo || localStorage.getItem('haven_desktop_promo_dismissed'),
promo_seen_android: seen.android_app_promo || localStorage.getItem('haven_ab_promo_nodisplay'),
recovery_notice_seen: localStorage.getItem('haven_recovery_notice_v1'),
};
for (const [prefKey, wasDismissed] of Object.entries(legacy)) {
if (!wasDismissed || this._userPrefs[prefKey] === 'true') continue;
this._userPrefs[prefKey] = 'true';
this.socket.emit('set-preference', { key: prefKey, value: 'true' });
}
// The server already answered get-recovery-notice-state for this
// connection before the pref landed, so cover this session by hand.
if (legacy.recovery_notice_seen) this._recoveryNoticeShown = true;
localStorage.setItem('haven_promo_prefs_migrated', '1');
}
} catch { /* storage unavailable: nothing to carry over */ }
this._runWelcomePromoQueue();
},
/** The app-promo sequencer. */
_runWelcomePromoQueue() {
// ── Build the queue ──
// Each entry: { id, modalId, prefKey, checkboxId, shouldShow }. A popup is
// filtered out only if its persisted "Don't show again" pref is set.
// shouldShow() handles per-platform skips (e.g. desktop promo is useless
// inside the desktop app itself).
const isMobile = /Android|iPhone|iPad|iPod|Mobile|Tablet/i.test(navigator.userAgent);
const isElectron = !!window.havenDesktop || navigator.userAgent.includes('Electron');
const allEntries = [
{
id: 'desktop_app_promo',
modalId: 'desktop-promo-modal',
prefKey: 'promo_seen_desktop',
checkboxId: 'desktop-promo-dismiss-check',
shouldShow: () => !isElectron && !isMobile,
},
{
id: 'android_app_promo',
modalId: 'android-beta-modal',
prefKey: 'promo_seen_android',
checkboxId: 'android-beta-dismiss-check',
shouldShow: () => true,
},
];
const queue = allEntries.filter(e => this._userPrefs[e.prefKey] !== 'true' && e.shouldShow() && document.getElementById(e.modalId));
if (!queue.length) return;
// ── Sequencer ──
let idx = 0;
let activeObserver = null;
const showCurrent = () => {
if (idx >= queue.length) return;
const entry = queue[idx];
const modal = document.getElementById(entry.modalId);
if (!modal) { idx++; return showCurrent(); }
// Inject (or refresh) the queue footer inside the modal card. We append
// to the inner card if we can find one; otherwise we fall back to the
// modal itself. Idempotent: we remove any previously-injected footer
// first so re-opening works cleanly.
const card = modal.querySelector('.modal-content, .modal-card, [class*="modal-content"], div') || modal;
modal.querySelectorAll('.haven-welcome-queue-footer').forEach(n => n.remove());
const remaining = queue.length - idx - 1;
const footer = document.createElement('div');
footer.className = 'haven-welcome-queue-footer';
footer.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;padding-top:12px;border-top:1px solid var(--border, rgba(255,255,255,0.08));font-size:0.75rem;color:var(--text-muted, #888);';
const isLast = remaining === 0;
footer.innerHTML = `
<span class="haven-welcome-queue-pos">${t('platform.welcome.position', { current: idx + 1, total: queue.length })}</span>
<span style="display:flex;align-items:center;gap:8px;">
${queue.length > 1 && !isLast ? `<button type="button" class="haven-welcome-queue-skip" style="background:none;border:none;color:var(--text-muted, #888);text-decoration:underline;cursor:pointer;font-size:0.75rem;padding:4px 8px;">${t('platform.welcome.skip_all')}</button>` : ''}
<button type="button" class="haven-welcome-queue-next" style="background:var(--accent, #5865f2);color:#fff;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-size:0.75rem;font-weight:600;">${t(isLast ? 'modals.common.done' : 'platform.welcome.next')}</button>
</span>
`;
// Find the deepest single-child div to append into; falls back gracefully
let host = modal.firstElementChild;
while (host && host.children && host.children.length === 1 && host.firstElementChild.tagName === 'DIV') {
host = host.firstElementChild;
}
(host || card).appendChild(footer);
footer.querySelector('.haven-welcome-queue-next').addEventListener('click', () => {
modal.style.display = 'none';
});
const skipBtn = footer.querySelector('.haven-welcome-queue-skip');
if (skipBtn) {
skipBtn.addEventListener('click', () => {
// Terminate the queue for this session. Nothing is persisted; only an
// explicit "Don't show again" tick (handled on close below) survives to
// the next login. The current modal's own checkbox is still honoured
// because hiding it fires the close handler.
idx = queue.length; // force terminate
modal.style.display = 'none';
});
}
// Show, then watch for close. Any close path (our footer, the modal's
// own Maybe Later / Install / overlay click) hides the modal; we react
// to display going from flex back to none/empty and advance.
modal.style.display = 'flex';
if (activeObserver) { activeObserver.disconnect(); activeObserver = null; }
activeObserver = new MutationObserver(() => {
const d = modal.style.display;
if (d === 'none' || d === '') {
activeObserver.disconnect();
activeObserver = null;
// Persist a dismissal only when the user ticked this modal's "Don't
// show again" box. A plain close (Next / Done / Maybe Later / overlay)
// is session-only: the queue has already advanced past it here, but it
// returns on the next login. This is deliberate (see commit rationale).
const checkbox = document.getElementById(entry.checkboxId);
if (checkbox && checkbox.checked) {
this.socket.emit('set-preference', { key: entry.prefKey, value: 'true' });
if (this._userPrefs) this._userPrefs[entry.prefKey] = 'true';
// The Android promo's box retires the top-bar banner too (#5594).
if (entry.prefKey === 'promo_seen_android') this._syncAndroidBanner?.();
}
idx++;
// Tiny delay so the close animation / focus shift completes before
// the next one opens. Feels less jarring than back-to-back flashes.
setTimeout(showCurrent, 350);
}
});
activeObserver.observe(modal, { attributes: true, attributeFilter: ['style'] });
};
// Defer initial show so the app shell finishes painting first.
setTimeout(showCurrent, 1200);
},
// ── Persisted timezone / time-format ────────────────────────────────────
// Storage (server-side user_preferences): `timezone` is an IANA zone id, so
// Intl resolves DST per-instant rather than freezing an offset; `time_format`
// is '12' or '24'. Nothing is asked at login: the modal opens from Settings,
// Localization, Configure Time, and until someone saves a zone every time
// follows the browser as before.
/** Common IANA zones for the rare engine without Intl.supportedValuesOf. */
_fallbackTimezones() {
return [
'UTC', 'America/Los_Angeles', 'America/Denver', 'America/Chicago',
'America/New_York', 'America/Sao_Paulo', 'Europe/London', 'Europe/Paris',
'Europe/Berlin', 'Europe/Moscow', 'Africa/Johannesburg', 'Asia/Dubai',
'Asia/Kolkata', 'Asia/Shanghai', 'Asia/Tokyo', 'Australia/Sydney',
'Pacific/Auckland',
];
},
/** Fill the timezone dropdown once from the full IANA list (or the fallback),
* always including the device's own zone and UTC. */
_buildTimezoneSelect() {
const sel = document.getElementById('timezone-select');
if (!sel || sel.dataset.built === '1') return;
let zones = [];
try { zones = (typeof Intl.supportedValuesOf === 'function') ? Intl.supportedValuesOf('timeZone') : []; } catch { zones = []; }
if (!zones.length) zones = this._fallbackTimezones();
let browserTz = 'UTC';
try { browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { /* keep UTC */ }
if (browserTz && !zones.includes(browserTz)) zones = [browserTz, ...zones];
if (!zones.includes('UTC')) zones = ['UTC', ...zones];
const frag = document.createDocumentFragment();
for (const z of zones) {
const o = document.createElement('option');
o.value = z;
o.textContent = z.replace(/_/g, ' ');
frag.appendChild(o);
}
sel.innerHTML = '';
sel.appendChild(frag);
sel.dataset.built = '1';
},
/** Open the modal. `firstRun` is informational; the buttons behave the same
* whether it was opened automatically or from settings. `onClose` runs after
* Skip / Remind later (Confirm reloads instead). */
_openTimezoneModal({ firstRun = false, onClose = null } = {}) {
const modal = document.getElementById('timezone-modal');
if (!modal) { if (onClose) onClose(); return; }
this._buildTimezoneSelect();
const tzSel = document.getElementById('timezone-select');
const fmtSel = document.getElementById('timeformat-select');
// Seed from the saved prefs, else the browser's current zone / clock.
let browserTz = 'UTC';
try { browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { /* keep UTC */ }
const wantTz = (this._userPrefs && this._userPrefs.timezone) || browserTz;
if (tzSel) {
tzSel.value = wantTz;
if (tzSel.value !== wantTz) { // zone not in the list: add and select it
const o = document.createElement('option');
o.value = wantTz; o.textContent = wantTz.replace(/_/g, ' ');
tzSel.appendChild(o); tzSel.value = wantTz;
}
}
const wantFmt = (this._userPrefs && this._userPrefs.time_format) || (this._tsm24hDefault?.() ? '24' : '12');
if (fmtSel) fmtSel.value = wantFmt;
this._tzModalOnClose = typeof onClose === 'function' ? onClose : null;
// Erase only shows once a zone is saved; it clears the saved zone and
// returns the account to the browser default.
const hasTz = !!(this._userPrefs && this._userPrefs.timezone);
const eraseBtn = document.getElementById('timezone-erase-btn');
if (eraseBtn) eraseBtn.style.display = hasTz ? '' : 'none';
if (!this._tzModalWired) {
this._tzModalWired = true;
const live = () => this._updateTimezonePreview();
tzSel?.addEventListener('change', live);
fmtSel?.addEventListener('change', live);
document.getElementById('timezone-erase-btn')?.addEventListener('click', () => this._resolveTimezoneModal('erase'));
document.getElementById('timezone-cancel-btn')?.addEventListener('click', () => this._resolveTimezoneModal('cancel'));
document.getElementById('timezone-confirm-btn')?.addEventListener('click', () => this._resolveTimezoneModal('confirm'));
// A click on the backdrop closes without saving, like Cancel.
modal.addEventListener('click', (e) => { if (e.target === modal) this._resolveTimezoneModal('cancel'); });
}
this._updateTimezonePreview();
// When opened from the settings panel, close it first so this modal is not
// stacked behind it (both share the same modal-overlay z-index). Harmless on
// the first-run path, where settings is already closed.
const settings = document.getElementById('settings-modal');
if (settings) settings.style.display = 'none';
modal.style.display = 'flex';
},
/** Live sample of the chosen zone + format, refreshed on every change. */
_updateTimezonePreview() {
const el = document.getElementById('timezone-preview');
if (!el) return;
const tz = document.getElementById('timezone-select')?.value;
const fmt = document.getElementById('timeformat-select')?.value;
const opts = { dateStyle: 'full', timeStyle: 'medium' };
if (tz) opts.timeZone = tz;
if (fmt === '12') opts.hour12 = true;
else if (fmt === '24') opts.hour12 = false;
try { el.textContent = new Date().toLocaleString(this._timeLocale?.(), opts); }
catch { el.textContent = new Date().toLocaleString(); }
},
/** Handle one of the three buttons. */
_resolveTimezoneModal(action) {
const modal = document.getElementById('timezone-modal');
const tz = document.getElementById('timezone-select')?.value;
const fmt = document.getElementById('timeformat-select')?.value === '24' ? '24' : '12';
const onClose = this._tzModalOnClose; this._tzModalOnClose = null;
if (modal) modal.style.display = 'none';
if (action === 'confirm') {
// Saves both prefs, then reloads so every already-rendered timestamp picks
// up the new zone/format. onClose (the promo queue) is intentionally not
// run; the reload re-evaluates it cleanly afterwards.
this._saveTimezonePrefs(tz, fmt);
return;
}
if (action === 'erase') {
// Clear the saved zone/format and reload so every timestamp reverts to the
// browser default. onClose is not run; the reload re-evaluates cleanly.
this._eraseTimezonePrefs();
return;
}
// Cancel persists nothing.
if (onClose) onClose();
},
/** Delete the saved timezone/format, then reload once the server confirms. */
_eraseTimezonePrefs() {
this._userPrefs = this._userPrefs || {};
delete this._userPrefs.timezone;
delete this._userPrefs.time_format;
this._updateTimezoneSummary?.();
const reload = () => { try { location.reload(); } catch { /* non-browser */ } };
if (!this.socket) { reload(); return; }
// Delete both rows and reload once their deletions are acknowledged.
const pending = new Set(['timezone', 'time_format']);
let timer = null;
const finish = () => { this.socket.off('preference-deleted', onDeleted); clearTimeout(timer); reload(); };
const onDeleted = ({ key } = {}) => { pending.delete(key); if (!pending.size) finish(); };
this.socket.on('preference-deleted', onDeleted);
timer = setTimeout(finish, 1500);
this.socket.emit('delete-preference', { key: 'timezone' });
this.socket.emit('delete-preference', { key: 'time_format' });
},
/** Persist timezone + format, wait for the server to confirm, then reload. */
_saveTimezonePrefs(tz, fmt) {
const zone = (typeof tz === 'string' && tz) ? tz : null;
const format = fmt === '24' ? '24' : '12';
this._userPrefs = this._userPrefs || {};
if (zone) this._userPrefs.timezone = zone;
this._userPrefs.time_format = format;
this._updateTimezoneSummary?.();
const reload = () => { try { location.reload(); } catch { /* non-browser */ } };
if (!this.socket || !zone) { reload(); return; }
// Reload only once the writes are acknowledged, so a fresh get-preferences
// after the reload is guaranteed to return them. A short timeout guards
// against a dropped ack so we never hang on this screen.
const pending = new Set(['timezone', 'time_format']);
let timer = null;
const finish = () => { this.socket.off('preference-saved', onSaved); clearTimeout(timer); reload(); };
const onSaved = ({ key } = {}) => { pending.delete(key); if (!pending.size) finish(); };
this.socket.on('preference-saved', onSaved);
timer = setTimeout(finish, 1500);
this.socket.emit('set-preference', { key: 'timezone', value: zone });
this.socket.emit('set-preference', { key: 'time_format', value: format });
},
/** Reflect the saved (or unset) state in the settings row. */
_updateTimezoneSummary() {
const el = document.getElementById('timezone-current-summary');
if (!el) return;
const tz = this._userPrefs && this._userPrefs.timezone;
const fmt = this._userPrefs && this._userPrefs.time_format;
if (tz) {
const fmtLabel = fmt ? ` · ${t(fmt === '24' ? 'settings.timezone_section.fmt_24' : 'settings.timezone_section.fmt_12')}` : '';
el.textContent = tz.replace(/_/g, ' ') + fmtLabel;
} else {
el.textContent = t('settings.timezone_section.not_set');
}
},
async _setupDesktopShortcuts() {
if (!window.havenDesktop?.shortcuts) return;
// Guard against duplicate listener attachment (called each time the nav item is clicked)
if (this._desktopShortcutsReady) return;
this._desktopShortcutsReady = true;
const keyMap = {
' ': 'Space', 'ArrowUp': 'Up', 'ArrowDown': 'Down',
'ArrowLeft': 'Left', 'ArrowRight': 'Right',
'Escape': 'Escape', 'Tab': 'Tab', 'Enter': 'Return',
'Backspace': 'Backspace', 'Delete': 'Delete',
// Electron spells the lock keys this way; the browser reports CapsLock.
'CapsLock': 'Capslock', 'NumLock': 'Numlock', 'ScrollLock': 'Scrolllock',
'Home': 'Home', 'End': 'End', 'PageUp': 'PageUp', 'PageDown': 'PageDown',
};
const formatAccel = (accel) => {
if (!accel) return '-';
return accel.replace('CommandOrControl', 'Ctrl/Cmd').replace('Control', 'Ctrl');
};
let config = {};
try { config = await window.havenDesktop.shortcuts.getConfig(); } catch (e) { console.warn('[Desktop] could not read the shortcut config', e); }
// Push to mute (ptm) is held like push to talk. A Haven Desktop that has
// it always lists it in the config, empty when unbound; an older one
// would take the key and ignore it, so the row stays hidden there.
const hasPtm = typeof config?.ptm === 'string';
if (!hasPtm) document.getElementById('shortcut-row-ptm')?.style.setProperty('display', 'none');
const actions = hasPtm ? ['mute', 'deafen', 'ptt', 'ptm'] : ['mute', 'deafen', 'ptt'];
actions.forEach(action => {
const keyEl = document.getElementById(`shortcut-key-${action}`);
const recordBtn = document.querySelector(`.shortcut-record-btn[data-action="${action}"]`);
const clearBtn = document.querySelector(`.shortcut-clear-btn[data-action="${action}"]`);
if (!keyEl || !recordBtn || !clearBtn) return;
keyEl.textContent = formatAccel(config[action] || '');
recordBtn.addEventListener('click', () => {
// Already recording: cancel
if (recordBtn.classList.contains('recording')) {
recordBtn.classList.remove('recording');
recordBtn.textContent = t('platform.shortcuts.record');
keyEl.classList.remove('recording-label');
// Re-register shortcuts after cancelling recording
window.havenDesktop.shortcuts.setConfig({}).catch((err) => { console.warn('[Desktop] could not update shortcuts', err); });
return;
}
recordBtn.classList.add('recording');
recordBtn.textContent = t('platform.shortcuts.press_key');
keyEl.classList.add('recording-label');
keyEl.textContent = '…';
// Temporarily clear the shortcut being recorded so its global hotkey
// doesn't swallow the keystroke before the BrowserView sees it
window.havenDesktop.shortcuts.setConfig({ [action]: '' }).catch((err) => { console.warn('[Desktop] could not update shortcuts', err); });
// (#5255) Three things the previous recorder couldn't do:
// 1. Lone modifiers (just Alt / Ctrl / Shift): useful while gaming so
// you can transmit without lifting a hand off WASD.
// 2. Extra mouse buttons (Mouse4 / Mouse5) for thumb-button push-to-talk.
// 3. The PTT mode (toggle vs hold) lives on a sibling control wired up
// elsewhere; the recorder itself just captures the keystroke / button.
//
// Bare-modifier capture works by deferring commit to keyup: keydown for
// a modifier alone arms a "pending lone modifier" that will commit if
// the user releases without ever pressing a non-modifier. Pressing a
// non-modifier in the meantime falls through to the original combo path
// and clears the pending state.
const MOD_KEYS = new Set(['Control', 'Meta', 'Alt', 'Shift']);
let pendingLoneMod = null;
const finish = async (accel) => {
document.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('keyup', onKeyUp, true);
document.removeEventListener('mousedown', onMouseDown, true);
recordBtn.classList.remove('recording');
recordBtn.textContent = t('platform.shortcuts.record');
keyEl.classList.remove('recording-label');
// The desktop IPC handler reports per-shortcut outcome:
// new shape: { mute: { ok, reason, accel } } (Desktop 1.4.20+)
// old shape: { mute: true|false } (Desktop ≤ 1.4.19)
// Plus the call itself can throw if the IPC bridge isn't wired up
// (e.g. running in a browser, or a desktop version too old to
// even have the shortcuts API).
try {
const res = await window.havenDesktop.shortcuts.setConfig({ [action]: accel });
const outcome = res && typeof res === 'object' ? res[action] : undefined;
const ok = (typeof outcome === 'object')
? !!outcome.ok
: (outcome !== false); // boolean (old shape) or undefined → trust it
if (!ok) {
await window.havenDesktop.shortcuts.setConfig({ [action]: config[action] || '' }).catch((err) => { console.warn('[Desktop] could not update shortcuts', err); });
keyEl.textContent = formatAccel(config[action] || '');
const reason = (typeof outcome === 'object' && outcome.reason) || '';
let msg;
if (reason === 'uiohook-unavailable') {
msg = t('platform.shortcuts.native_hook_error', { shortcut: accel });
} else if (reason === 'conflict') {
msg = t('platform.shortcuts.conflict', { shortcut: accel });
} else {
msg = t('platform.shortcuts.register_failed');
}
this._showToast?.(msg, 'error');
return;
}
config[action] = accel;
keyEl.textContent = formatAccel(accel);
} catch (err) {
await window.havenDesktop.shortcuts.setConfig({ [action]: config[action] || '' }).catch((err) => { console.warn('[Desktop] could not update shortcuts', err); });
keyEl.textContent = formatAccel(config[action] || '');
this._showToast?.(t('platform.shortcuts.register_failed'), 'error');
}
};
const onKeyDown = (e) => {
e.preventDefault();
e.stopPropagation();
if (MOD_KEYS.has(e.key)) {
// Don't commit yet. Wait for keyup to decide if this is a lone-mod
// press or the modifier half of a combo.
pendingLoneMod = e.key;
keyEl.textContent = `${e.key}…`;
return;
}
// Non-modifier pressed: kill the pending lone-mod and commit a combo.
pendingLoneMod = null;
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('CommandOrControl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
const mapped = keyMap[e.key] || (e.key.length === 1 ? e.key.toUpperCase() : e.key);
parts.push(mapped);
finish(parts.join('+'));
};
const onKeyUp = (e) => {
// Only commit a lone modifier if the user released the SAME modifier
// they pressed and never pressed anything else in between.
if (!MOD_KEYS.has(e.key)) return;
if (pendingLoneMod !== e.key) return;
pendingLoneMod = null;
// Map "Control"/"Meta" → "CommandOrControl" to match Electron's
// accelerator format. "Alt" / "Shift" pass through.
const mapped = (e.key === 'Control' || e.key === 'Meta') ? 'CommandOrControl' : e.key;
finish(mapped);
};
const onMouseDown = (e) => {
// 0/1/2 are left/middle/right; leave those alone so the user can still
// click around. 3+ are the extra mouse buttons (mouse4 / mouse5).
if (e.button < 3) return;
e.preventDefault();
e.stopPropagation();
pendingLoneMod = null;
finish(`Mouse${e.button + 1}`);
};
document.addEventListener('keydown', onKeyDown, true);
document.addEventListener('keyup', onKeyUp, true);
document.addEventListener('mousedown', onMouseDown, true);
});
clearBtn.addEventListener('click', async () => {
try {
await window.havenDesktop.shortcuts.setConfig({ [action]: '' });
config[action] = '';
keyEl.textContent = '-';
} catch (err) { console.warn('[Desktop] could not clear the shortcut', err); }
});
});
// (#5255) PTT mode select: toggle vs hold-to-transmit. Stored on the same
// shortcuts config object alongside the keybinds. Default to "hold" since
// that's what most voice apps use and what the issue reporter wanted.
const pttModeSel = document.getElementById('ptt-mode-select');
if (pttModeSel) {
pttModeSel.value = (config.pttMode === 'toggle') ? 'toggle' : 'hold';
pttModeSel.addEventListener('change', async () => {
try {
await window.havenDesktop.shortcuts.setConfig({ pttMode: pttModeSel.value });
config.pttMode = pttModeSel.value;
} catch (err) {
this._showToast?.(t('platform.shortcuts.ptt_save_failed'), 'error');
}
});
}
},
/* ── Desktop App Preferences (start on login, tray, SDR) ── */
async _setupDesktopAppPrefs() {
if (!window.havenDesktop?.prefs) return;
if (this._desktopPrefsReady) return;
this._desktopPrefsReady = true;
let prefs = {};
try { prefs = await window.havenDesktop.prefs.get(); } catch (err) { console.warn('[Desktop] could not read desktop preferences', err); }
const startEl = document.getElementById('pref-start-on-login');
const hiddenEl = document.getElementById('pref-start-hidden');
const hiddenRow = document.getElementById('pref-start-hidden-row');
const trayEl = document.getElementById('pref-minimize-to-tray');
const flashEl = document.getElementById('pref-flash-taskbar');
const sdrEl = document.getElementById('pref-force-sdr');
const menuBarEl = document.getElementById('pref-hide-menu-bar');
const gpuVsyncEl = document.getElementById('pref-disable-gpu-vsync');
const unlimitFpsEl = document.getElementById('pref-unlimit-frame-rate');
const linuxVaapiEl = document.getElementById('pref-linux-vaapi');
const linuxX11El = document.getElementById('pref-linux-x11');
const versionEl = document.getElementById('desktop-version-info');
if (startEl) { startEl.checked = !!prefs.startOnLogin; }
if (hiddenEl) { hiddenEl.checked = !!prefs.startHidden; }
if (hiddenRow) { hiddenRow.style.display = prefs.startOnLogin ? '' : 'none'; }
if (trayEl) { trayEl.checked = !!prefs.minimizeToTray; }
// Desktop apps from before this switch do not have it, so it shows only
// where it works.
const canFlash = typeof window.havenDesktop.prefs.setFlashTaskbar === 'function';
const flashRow = document.getElementById('pref-flash-taskbar-row');
if (flashRow) flashRow.style.display = canFlash ? '' : 'none';
if (flashEl) { flashEl.checked = prefs.flashTaskbar !== false; }
if (sdrEl) { sdrEl.checked = !!prefs.forceSDR; }
if (menuBarEl) { menuBarEl.checked = !!prefs.hideMenuBar; }
if (gpuVsyncEl) { gpuVsyncEl.checked = !!prefs.disableGpuVsync; }
if (unlimitFpsEl) { unlimitFpsEl.checked = !!prefs.unlimitFrameRate; }
if (linuxVaapiEl) { linuxVaapiEl.checked = !!prefs.linuxVaapiBypass; }
if (linuxX11El) { linuxX11El.checked = !!prefs.linuxForceX11; }
// Show desktop version
if (versionEl && window.havenDesktop.getVersion) {
try {
const v = await window.havenDesktop.getVersion();
versionEl.textContent = `Haven Desktop v${v}`;
} catch (err) { console.warn('[Desktop] could not read the app version', err); }
}
startEl?.addEventListener('change', async () => {
try { await window.havenDesktop.prefs.setStartOnLogin(startEl.checked); }
catch { startEl.checked = !startEl.checked; }
// Show/hide the start-hidden option
if (hiddenRow) hiddenRow.style.display = startEl.checked ? '' : 'none';
});
hiddenEl?.addEventListener('change', async () => {
try { await window.havenDesktop.prefs.setStartHidden(hiddenEl.checked); }
catch { hiddenEl.checked = !hiddenEl.checked; }
});
trayEl?.addEventListener('change', async () => {
try { await window.havenDesktop.prefs.setMinimizeToTray(trayEl.checked); }
catch { trayEl.checked = !trayEl.checked; }
});
flashEl?.addEventListener('change', async () => {
try { await window.havenDesktop.prefs.setFlashTaskbar(flashEl.checked); }
catch (err) {
console.warn('[Desktop] could not save the taskbar flash setting', err);
flashEl.checked = !flashEl.checked;
}
});
sdrEl?.addEventListener('change', async () => {
try {
const res = await window.havenDesktop.prefs.setForceSDR(sdrEl.checked);
if (res?.requiresRestart) {
this._showToast(t('platform.desktop.color_updated'), 'info');
}
} catch { sdrEl.checked = !sdrEl.checked; }
});
menuBarEl?.addEventListener('change', async () => {
try { await window.havenDesktop.prefs.setHideMenuBar(menuBarEl.checked); }
catch { menuBarEl.checked = !menuBarEl.checked; }
});
// (#35) Nvidia G-Sync / VRR FPS-drop workarounds. Both flags are Chromium
// command-line switches read at app boot, so flipping them only takes effect
// after a restart, so surface a toast saying so.
gpuVsyncEl?.addEventListener('change', async () => {
try {
const res = await window.havenDesktop.prefs.setDisableGpuVsync(gpuVsyncEl.checked);
if (res?.requiresRestart) {
this._showToast?.(t('platform.desktop.vsync_updated'), 'info');
}
} catch { gpuVsyncEl.checked = !gpuVsyncEl.checked; }
});
unlimitFpsEl?.addEventListener('change', async () => {
try {
const res = await window.havenDesktop.prefs.setUnlimitFrameRate(unlimitFpsEl.checked);
if (res?.requiresRestart) {
this._showToast?.(t('platform.desktop.frame_cap_updated'), 'info');
}
} catch { unlimitFpsEl.checked = !unlimitFpsEl.checked; }
});
linuxVaapiEl?.addEventListener('change', async () => {
try {
const res = await window.havenDesktop.prefs.setLinuxVaapiBypass(linuxVaapiEl.checked);
if (res?.requiresRestart) {
this._showToast?.(t('platform.desktop.vaapi_updated'), 'info');
}
} catch (err) {
console.warn('[Desktop] could not save the VA-API setting', err);
linuxVaapiEl.checked = !linuxVaapiEl.checked;
}
});
linuxX11El?.addEventListener('change', async () => {
try {
const res = await window.havenDesktop.prefs.setLinuxForceX11(linuxX11El.checked);
if (res?.requiresRestart) {
this._showToast?.(t('platform.desktop.x11_updated'), 'info');
}
} catch (err) {
console.warn('[Desktop] could not save the X11 setting', err);
linuxX11El.checked = !linuxX11El.checked;
}
});
},
/* ── E2E Encryption Helpers ──────────────────────────── */
async _initE2E() {
if (typeof HavenE2E === 'undefined') return;
try {
this.e2e = new HavenE2E();
// Read the password-derived wrapping key from sessionStorage (set during login).
// On auto-login (JWT, no password) this will be null (IndexedDB-only mode).
const wrappingKey = sessionStorage.getItem('haven_e2e_wrap') || null;
const ok = await this.e2e.init(this.socket, wrappingKey);
// Keep wrapping key in memory for cross-device sync (conflict resolution).
// Clear from sessionStorage but retain privately for backup restoration.
// Also persist to localStorage so server list sync works across page reloads.
if (wrappingKey) {
this._e2eWrappingKey = wrappingKey;
sessionStorage.removeItem('haven_e2e_wrap');
try { localStorage.setItem('haven_sync_key', wrappingKey); } catch { /* private mode */ }
} else {
// On auto-login (no password), recover the sync key from localStorage
try {
const savedKey = localStorage.getItem('haven_sync_key');
if (savedKey) this._e2eWrappingKey = savedKey;
} catch { /* storage blocked (private mode): keep the default */ }
}
if (ok) {
await this._e2eSetupListeners();
this._updateE2EIndicator();
// If keys were auto-reset during init (backup unwrap failed), notify
if (this.e2e.keysWereReset) {
setTimeout(() => {
this._appendE2ENotice(t('platform.e2e.keys_regenerated', { date: this._fmtDateTime(new Date()) }));
}, 500);
}
} else {
console.warn('[E2E] Init returned false, encryption unavailable');
// Don't null out e2e if server backup exists; we may sync later
if (!this.e2e._serverBackupExists) this.e2e = null;
}
} catch (err) {
console.warn('[E2E] Init failed:', err);
this.e2e = null;
}
// Sync server list with server-side encrypted backup (piggybacks on wrapping key)
try {
const syncKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (syncKey && this.serverManager) {
await this.serverManager.syncWithServer(this.token, syncKey);
this._renderServerBar();
this._pushServersToDesktopHistory();
// Re-sync periodically (every 5 min) so cross-device changes propagate
// without requiring a full page reload or re-login
if (!this._serverSyncInterval) {
this._serverSyncInterval = setInterval(async () => {
const key = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (key && this.serverManager && this.token) {
try {
await this.serverManager.syncWithServer(this.token, key);
this._renderServerBar();
this._pushServersToDesktopHistory();
} catch (err) { console.warn('[Sync] server list sync failed', err); }
}
}, 5 * 60 * 1000);
}
// Also sync when the tab becomes visible (user switching back from another server)
if (!this._serverSyncVisibility) {
this._serverSyncVisibility = true;
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState !== 'visible') return;
const key = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (key && this.serverManager && this.token) {
try {
await this.serverManager.syncWithServer(this.token, key);
this._renderServerBar();
this._pushServersToDesktopHistory();
} catch (err) { console.warn('[Sync] server list sync failed', err); }
}
});
}
}
} catch (err) {
console.warn('[ServerSync] Post-login sync failed:', err.message);
}
},
/** Publish our key and wire up partner-key listeners (idempotent). */
async _e2eSetupListeners() {
// Publish our public key (force if keys were explicitly reset)
// A successful publish also sets up the signing key (see publishKey). A
// conflict means this device holds a stale key, which must not be used to
// open or replace the signing key backup until it has synced below.
const result = await this.e2e.publishKey(this.socket, this.e2e.keysWereReset);
// Handle publish conflict: server has a different key (another device changed it).
// Sync from the server backup instead of overwriting.
if (result.conflict) {
console.warn('[E2E] Server has a different key, syncing from server backup...');
const wrappingKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (wrappingKey) {
const synced = await this.e2e.syncFromServer(this.socket, wrappingKey);
if (synced.ok) {
// After sync, re-publish: the key now matches the server backup,
// so the server should accept it. Use force=true to handle the edge case
// where the public_key column differs from the encrypted backup.
await this.e2e.publishKey(this.socket, true);
this._dmPublicKeys = {};
// Re-fetch partner key so any in-view DM messages can decrypt immediately.
const _syncCh = this.channels && this.channels.find(c => c.code === this.currentChannel);
if (_syncCh && _syncCh.is_dm) await this._fetchDMPartnerKey(_syncCh);
this._showToast(t('platform.e2e.keys_synced_device'), 'success');
} else {
this._showToast(this._e2eSyncErrorMessage(synced.reason), 'error', null, 8000);
}
} else {
// No wrapping key: need password
this._showToast(t('platform.e2e.keys_changed'), 'error');
this._e2ePwPendingAction = () => this._syncE2EFromServer();
this._showE2EPasswordModal();
}
}
// Only attach socket listeners once
if (this._e2eListenersAttached) return;
this._e2eListenersAttached = true;
this.socket.on('public-key-result', (data) => {
if (!data || !data.userId) return;
// Remembered so the DM header can say messages there are not encrypted.
if (!data.jwk) {
this._e2eNoKey.add(data.userId);
this._updateE2EIndicator();
return;
}
this._e2eNoKey.delete(data.userId);
const oldKey = this._dmPublicKeys[data.userId];
const changed = oldKey && (oldKey.x !== data.jwk.x || oldKey.y !== data.jwk.y);
// The first key seen for a partner is remembered on this device.
if (data.userId !== this.user?.id) this._e2ePinCheck(data.userId, data.jwk);
this._dmPublicKeys[data.userId] = data.jwk;
this._updateE2EIndicator();
if (changed && this.e2e) {
this.e2e.clearSharedKey(data.userId);
console.warn(`[E2E] Partner ${data.userId} key changed, cache invalidated`);
// Noted in their DM for the rest of the session (see _renderMessages).
this._noteE2EKeyChange(data.userId);
}
// Resolve any pending requestPartnerKey promises for this user
// (not used when e2e.requestPartnerKey handles it, but covers
// the case where _fetchDMPartnerKey fires a fire-and-forget)
this._retryDecryptForUser(data.userId);
});
console.log('[E2E] Listeners attached, key published');
// Listen for key sync from another session of the same user
this.socket.on('e2e-key-sync', async () => {
console.log('[E2E] Key changed on another session, syncing...');
const wrappingKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (wrappingKey && this.e2e) {
const synced = await this.e2e.syncFromServer(this.socket, wrappingKey);
if (synced.ok) {
await this.e2e.publishKey(this.socket);
this._dmPublicKeys = {};
this._showToast(t('platform.e2e.keys_synced'), 'success');
// Re-fetch messages if in a DM to re-decrypt
const ch = this.channels.find(c => c.code === this.currentChannel);
if (ch && ch.is_dm) {
this._oldestMsgId = null;
this._noMoreHistory = false;
this._loadingHistory = false;
this._historyBefore = null;
this._newestMsgId = null;
this._noMoreFuture = true;
this._loadingFuture = false;
this._historyAfter = null;
// Fetch partner key first; otherwise messages land with an empty
// _dmPublicKeys and show '[Encrypted: waiting for key...]' forever.
await this._fetchDMPartnerKey(ch);
this.socket.emit('get-messages', { code: this.currentChannel });
}
return;
}
}
// No wrapping key or sync failed: prompt for password
this._showToast(t('platform.e2e.keys_changed'), 'error');
this._e2ePwPendingAction = () => this._syncE2EFromServer();
this._showE2EPasswordModal();
});
},
/**
* Recover E2E keys from the server-side encrypted backup.
* This is the non-destructive option: it re-fetches and unwraps the existing
* keypair rather than generating fresh ones, so encrypted messages that were
* readable before remain readable after recovery. Use this when a device
* ended up in ghost-state (e.g. after auto-login without a password or after
* IndexedDB was cleared). Does NOT overwrite the server backup.
*
* Called from the "Recover Keys from Backup" button in the E2E dropdown.
* Like Reset, this bypasses _requireE2E so it works even when E2E is broken.
*/
async _recoverE2EFromBackup() {
const wrappingKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (!wrappingKey) {
// Need password first. Set a pending action so the modal resolves here.
this._e2ePwPendingAction = () => this._recoverE2EFromBackup();
this._showE2EPasswordModal();
return;
}
// Ensure we have an E2E instance even if init failed.
if (!this.e2e) {
if (typeof HavenE2E !== 'undefined') {
this.e2e = new HavenE2E();
await this.e2e._openDB();
} else {
this._showToast(t('platform.e2e.module_unavailable'), 'error');
return;
}
}
this._showToast(t('platform.e2e.recovering'), 'info');
const synced = await this.e2e.syncFromServer(this.socket, wrappingKey);
if (synced.ok) {
await this.e2e.publishKey(this.socket);
this._dmPublicKeys = {};
this._appendE2ENotice(t('platform.e2e.keys_recovered_notice', { date: this._fmtDateTime(new Date()) }));
this._showToast(t('platform.e2e.keys_recovered'), 'success');
// Re-fetch messages if currently in a DM so they attempt decryption again.
const ch = this.channels && this.channels.find(c => c.code === this.currentChannel);
if (ch && ch.is_dm) {
this._oldestMsgId = null;
this._noMoreHistory = false;
this._loadingHistory = false;
this._historyBefore = null;
this._newestMsgId = null;
this._noMoreFuture = true;
this._loadingFuture = false;
this._historyAfter = null;
// Fetch partner key BEFORE requesting messages. _dmPublicKeys was just
// cleared, so without this every incoming message decryption misses the
// shared key and shows '[Encrypted: waiting for key...]' indefinitely.
await this._fetchDMPartnerKey(ch);
this.socket.emit('get-messages', { code: this.currentChannel });
}
} else {
this._showToast(this._e2eSyncErrorMessage(synced.reason), 'error', null, 10000);
}
},
/**
* Build a user-facing error message for a syncFromServer failure reason.
* Critical: never advise Reset for 'bad-password' or 'network'; that destroys DMs.
*/
_e2eSyncErrorMessage(reason) {
switch (reason) {
case 'no-backup':
return t('platform.e2e.errors.no_backup');
case 'bad-password':
return t('platform.e2e.errors.bad_password');
case 'network':
return t('platform.e2e.errors.network');
case 'no_wrapping_key':
case 'bad-password-empty':
return t('platform.e2e.errors.no_password');
default:
return t('platform.e2e.errors.recovery_failed');
}
},
/**
* Sync E2E keys from the server backup (called after password prompt or conflict detection).
*/
async _syncE2EFromServer() {
const wrappingKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (!wrappingKey || !this.e2e) return;
const synced = await this.e2e.syncFromServer(this.socket, wrappingKey);
if (synced.ok) {
await this.e2e.publishKey(this.socket);
this._dmPublicKeys = {};
this._showToast(t('platform.e2e.keys_synced_device'), 'success');
// Re-fetch messages if in a DM
const ch = this.channels.find(c => c.code === this.currentChannel);
if (ch && ch.is_dm) {
this._oldestMsgId = null;
this._noMoreHistory = false;
this._loadingHistory = false;
this._historyBefore = null;
this._newestMsgId = null;
this._noMoreFuture = true;
this._loadingFuture = false;
this._historyAfter = null;
await this._fetchDMPartnerKey(ch);
this.socket.emit('get-messages', { code: this.currentChannel });
}
} else {
this._showToast(this._e2eSyncErrorMessage(synced.reason), 'error', null, 8000);
}
},
/**
* Require E2E to be ready before executing an action.
* If E2E isn't ready (no password was provided at login), shows the password prompt.
* @param {Function} action - Callback to run once E2E is available
*/
_requireE2E(action) {
if (this.e2e && this.e2e.ready) {
action();
return;
}
// E2E not available: prompt for password
this._e2ePwPendingAction = action;
this._showE2EPasswordModal();
},
/**
* Show the E2E password prompt modal.
*/
_showE2EPasswordModal() {
const modal = document.getElementById('e2e-password-modal');
const input = document.getElementById('e2e-pw-input');
const errorEl = document.getElementById('e2e-pw-error');
const submitBtn = document.getElementById('e2e-pw-submit-btn');
input.value = '';
errorEl.style.display = 'none';
errorEl.textContent = '';
submitBtn.disabled = false;
submitBtn.textContent = t('platform.e2e.unlock');
// (#12) An SSO account has no Haven password. Its key is wrapped with the
// separate encryption passphrase set at first sign-in. Ask for that instead,
// or the prompt tells the user to enter a password they do not have. An
// account that chose its own encryption passphrase is asked for that too.
if (this._e2eUsesPassphrase()) {
const titleEl = modal.querySelector('h3 span');
const descEl = modal.querySelector('.e2e-pw-desc');
if (titleEl) titleEl.textContent = t('platform.e2e.passphrase_required');
if (descEl) descEl.textContent = t(this.user?.isSso ? 'platform.e2e.passphrase_desc' : 'platform.e2e.passphrase_desc_own');
input.placeholder = t('platform.e2e.passphrase_placeholder');
}
// Check rate limit
const now = Date.now();
this._e2ePwAttempts = (this._e2ePwAttempts || []).filter(t => now - t < 60_000);
if (this._e2ePwAttempts.length >= 5) {
const oldest = this._e2ePwAttempts[0];
const waitSec = Math.ceil((60_000 - (now - oldest)) / 1000);
errorEl.textContent = t('platform.e2e.too_many_attempts', { seconds: waitSec });
errorEl.style.display = 'block';
submitBtn.disabled = true;
}
modal.style.display = 'flex';
setTimeout(() => input.focus(), 50);
},
/**
* Submit the E2E password prompt: verify against server, derive wrapping key, init E2E.
*/
async _submitE2EPassword() {
const modal = document.getElementById('e2e-password-modal');
const input = document.getElementById('e2e-pw-input');
const errorEl = document.getElementById('e2e-pw-error');
const submitBtn = document.getElementById('e2e-pw-submit-btn');
const password = input.value;
if (!password) {
errorEl.textContent = t(this._e2eUsesPassphrase() ? 'platform.e2e.enter_passphrase' : 'platform.e2e.enter_password');
errorEl.style.display = 'block';
return;
}
// Rate limit check
const now = Date.now();
this._e2ePwAttempts = (this._e2ePwAttempts || []).filter(t => now - t < 60_000);
if (this._e2ePwAttempts.length >= 5) {
const oldest = this._e2ePwAttempts[0];
const waitSec = Math.ceil((60_000 - (now - oldest)) / 1000);
errorEl.textContent = t('platform.e2e.too_many_attempts', { seconds: waitSec });
errorEl.style.display = 'block';
submitBtn.disabled = true;
return;
}
// Record attempt
this._e2ePwAttempts.push(now);
submitBtn.disabled = true;
submitBtn.textContent = t('platform.e2e.verifying');
errorEl.style.display = 'none';
try {
// (#12) SSO accounts have nothing on the server to check this against:
// the passphrase is deliberately never sent anywhere, so the server has no
// copy and no hash of it. Unwrapping the key IS the check: a wrong
// passphrase fails the AES-GCM auth tag below, and init() leaves the
// existing backup untouched rather than regenerating over it.
const data = this._e2eUsesPassphrase()
? { valid: true }
: await (await fetch('/api/auth/verify-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.token}` },
body: JSON.stringify({ password })
})).json();
if (!data.valid) {
const remaining = 5 - this._e2ePwAttempts.length;
errorEl.textContent = remaining > 0
? t(remaining === 1 ? 'platform.e2e.incorrect_one' : 'platform.e2e.incorrect_other', { count: remaining })
: t('platform.e2e.locked_out');
errorEl.style.display = 'block';
submitBtn.disabled = remaining <= 0;
submitBtn.textContent = t('platform.e2e.unlock');
input.value = '';
input.focus();
return;
}
// Password correct: derive wrapping key and init E2E
submitBtn.textContent = t('platform.e2e.unlocking');
const wrappingKey = await HavenE2E.deriveWrappingKey(password);
sessionStorage.setItem('haven_e2e_wrap', wrappingKey);
this._e2eWrappingKey = wrappingKey;
// If a key reset is pending, skip normal init (it may fail if backup
// is encrypted with a different password). Reset generates fresh keys.
if (this._e2eResetPending) {
this._e2eResetPending = false;
this._closeE2EPasswordModal();
await this._performE2EKeyReset();
return;
}
// Re-initialize E2E with the wrapping key
if (!this.e2e) this.e2e = new HavenE2E();
const ok = await this.e2e.init(this.socket, wrappingKey);
if (ok) {
// Set up E2E listeners (handles publish + conflict resolution)
await this._e2eSetupListeners();
this._updateE2EIndicator();
this._closeE2EPasswordModal();
this._showToast(t('platform.e2e.unlocked'), 'success');
// Execute the pending action
if (this._e2ePwPendingAction) {
const action = this._e2ePwPendingAction;
this._e2ePwPendingAction = null;
action();
}
} else {
errorEl.textContent = t('platform.e2e.init_failed');
errorEl.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = t('platform.e2e.unlock');
}
} catch (err) {
console.error('[E2E] Password prompt error:', err);
errorEl.textContent = t('platform.e2e.unexpected_error');
errorEl.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = t('platform.e2e.unlock');
}
},
/** SSO accounts, and accounts that chose one, unlock E2E with a passphrase
* the server never sees rather than with the login password. */
_e2eUsesPassphrase() {
return !!(this.user?.isSso || this.user?.e2ePassphrase);
},
/**
* Close the E2E password prompt modal.
*/
_closeE2EPasswordModal() {
const modal = document.getElementById('e2e-password-modal');
modal.style.display = 'none';
document.getElementById('e2e-pw-input').value = '';
this._e2ePwPendingAction = null;
this._e2eResetPending = false;
},
/**
* Get the E2E partner for the current DM channel.
* Returns { userId, publicKeyJwk } or null.
*/
_getE2EPartner() {
return this._getE2EPartnerFor(this.currentChannel);
},
/** Like _getE2EPartner but for an arbitrary DM channel code (used by the
* DM PiP, which sends to its own code while another channel is active). */
_getE2EPartnerFor(code) {
if (!this.e2e || !this.e2e.ready) return null;
const ch = this.channels.find(c => c.code === code);
if (ch && ch.is_dm && ch.is_group) return { group: true, code: ch.code, userId: null, publicKeyJwk: null };
if (!ch || !ch.is_dm || !ch.dm_target) return null;
const jwk = this._dmPublicKeys[ch.dm_target.id];
return jwk ? { userId: ch.dm_target.id, publicKeyJwk: jwk } : null;
},
// ── Partner key pinning ───────────────────────────────
// The first key seen for each DM partner is remembered on this device. A
// different one later means they reset their keys, or someone in between
// swapped it, and nothing is encrypted to it until the sender accepts it.
_e2ePinStore() {
return `haven_e2e_pins_${this.user?.id}`;
},
_e2ePinFingerprint(jwk) {
return `${jwk.x}.${jwk.y}`;
},
_e2ePins() {
try { return JSON.parse(localStorage.getItem(this._e2ePinStore()) || '{}') || {}; } catch { return {}; }
},
_e2ePinSet(userId, jwk) {
try {
const pins = this._e2ePins();
pins[userId] = this._e2ePinFingerprint(jwk);
localStorage.setItem(this._e2ePinStore(), JSON.stringify(pins));
} catch { /* no storage: nothing is remembered */ }
},
/** 'new' (and now remembered), 'same', or 'changed'. */
_e2ePinCheck(userId, jwk) {
const pinned = this._e2ePins()[userId];
if (!pinned) { this._e2ePinSet(userId, jwk); return 'new'; }
return pinned === this._e2ePinFingerprint(jwk) ? 'same' : 'changed';
},
/** Web Crypto only exists on HTTPS (and localhost), so plain HTTP has no E2E. */
_e2eSupported() {
return typeof HavenE2E !== 'undefined' && !!(window.crypto && window.crypto.subtle);
},
/**
* Every DM send asks here first. Resolves { partner } to encrypt for,
* { partner: null } to send as it is (not a DM, or the sender agreed to send
* it unencrypted), or null when the sender backed out. A DM used to go out
* unencrypted, without asking, whenever a key was missing, and a partner's
* key could be swapped without anyone noticing.
*/
_dmSendGate(code) {
const ch = this.channels?.find(c => c.code === code);
if (ch?.is_dm && ch.is_group) return this._groupSendGate(code);
if (!ch || !ch.is_dm || !ch.dm_target) return Promise.resolve({ partner: null });
// One question per conversation at a time, so a batch of files asks once.
if (this._dmGateAsking.has(code)) return this._dmGateAsking.get(code);
const asking = this._dmSendGateAsk(ch).finally(() => this._dmGateAsking.delete(code));
this._dmGateAsking.set(code, asking);
return asking;
},
async _dmSendGateAsk(ch) {
const code = ch.code;
const partnerId = ch.dm_target.id;
const name = ch.dm_target.username;
const self = ch.is_self_dm || partnerId === this.user?.id;
let partner = this._getE2EPartnerFor(code);
if (!partner && this.e2e?.ready) {
try {
const jwk = await this.e2e.requestPartnerKey(this.socket, partnerId);
if (jwk) { this._dmPublicKeys[partnerId] = jwk; partner = this._getE2EPartnerFor(code); }
} catch { /* same as no key */ }
}
if (partner) {
if (self || this._e2ePinCheck(partnerId, partner.publicKeyJwk) !== 'changed') return { partner };
const choice = await this._askChoice(
t('platform.e2e.key_changed_title', { name }),
t('platform.e2e.key_changed_body', { name }),
[
{ id: 'cancel', label: t('modals.common.cancel') },
{ id: 'verify', label: t('platform.e2e.key_changed_verify') },
{ id: 'trust', label: t('platform.e2e.key_changed_trust'), danger: true },
]
);
if (choice === 'verify') this._showE2EVerification(code);
if (choice !== 'trust') return null;
this._e2ePinSet(partnerId, partner.publicKeyJwk);
this._updateE2EIndicator();
return { partner };
}
if (this._plainDmOk.has(code)) return { partner: null };
const supported = this._e2eSupported();
const locked = supported && !this.e2e?.ready;
const buttons = [{ id: 'cancel', label: t('modals.common.cancel') }];
if (locked) buttons.push({ id: 'unlock', label: t('platform.e2e.plain_unlock'), accent: true });
buttons.push({ id: 'send', label: t('platform.e2e.plain_send'), danger: true });
let why;
if (!supported) why = t('platform.e2e.plain_unsupported');
else if (locked) why = t('platform.e2e.plain_locked');
else why = t('platform.e2e.plain_no_key', { name });
const choice = await this._askChoice(t('platform.e2e.plain_title'), why, buttons);
if (choice === 'unlock') this._requireE2E(() => this._updateE2EIndicator());
if (choice !== 'send') return null;
this._plainDmOk.add(code);
return { partner: null };
},
/** A partner's key change is noted in their DM for the rest of the session. */
_noteE2EKeyChange(userId) {
if (this._e2eKeyNotices.has(userId)) return;
const ch = this.channels?.find(c => c.is_dm && c.dm_target && c.dm_target.id === userId);
if (!ch) return;
this._e2eKeyNotices.set(userId, t('platform.e2e.partner_keys_changed', { name: ch.dm_target.username, date: this._fmtDateTime(new Date()) }));
},
/** The lock in a DM's header shows whether what you send there is encrypted. */
_updateE2EIndicator() {
const btn = document.getElementById('e2e-menu-btn');
const ch = this.channels?.find(c => c.code === this.currentChannel);
if (!btn || !ch || !ch.is_dm || !ch.dm_target) return;
const id = ch.dm_target.id;
const name = ch.dm_target.username;
const jwk = this._dmPublicKeys[id];
const self = ch.is_self_dm || id === this.user?.id;
const pinned = jwk && !self ? this._e2ePins()[id] : null;
let icon = '🔐', off = false, changed = false, title = t('platform.e2e.status_on');
if (!this._e2eSupported()) {
icon = '🔓'; off = true; title = t('platform.e2e.status_off_unsupported');
} else if (!this.e2e?.ready) {
icon = '🔓'; off = true; title = t('platform.e2e.status_off_locked');
} else if (pinned && pinned !== this._e2ePinFingerprint(jwk)) {
icon = '⚠️'; changed = true; title = t('platform.e2e.status_changed', { name });
// Said in the conversation as well. After a reload the old key is not
// in memory, so this comparison is the only place the change shows up.
this._noteE2EKeyChange(id);
} else if (!jwk && this._e2eNoKey.has(id)) {
icon = '🔓'; off = true; title = t('platform.e2e.status_off_no_key', { name });
}
btn.textContent = icon;
btn.classList.toggle('e2e-off', off);
btn.classList.toggle('e2e-changed', changed);
btn.title = title;
},
/**
* Re-fetch messages when a partner's key arrives (fixes key/message race).
*/
_retryDecryptForUser(userId) {
const ch = this.channels.find(c => c.code === this.currentChannel);
if (!ch || !ch.is_dm || !ch.dm_target || ch.dm_target.id !== userId) return;
this._oldestMsgId = null;
this._noMoreHistory = false;
this._loadingHistory = false;
this._historyBefore = null;
this._newestMsgId = null;
this._noMoreFuture = true;
this._loadingFuture = false;
this._historyAfter = null;
this.socket.emit('get-messages', { code: this.currentChannel });
},
/**
* Fetch the DM partner's public key (fire-and-forget, or awaitable via promise).
* Always re-fetches to detect key changes across devices.
*/
async _fetchDMPartnerKey(channel) {
if (!this.e2e || !this.e2e.ready) return;
if (!channel || !channel.is_dm || !channel.dm_target) return;
const partnerId = channel.dm_target.id;
const jwk = await this.e2e.requestPartnerKey(this.socket, partnerId);
if (jwk) this._dmPublicKeys[partnerId] = jwk;
},
/**
* Show E2E verification code modal for the current DM.
*/
async _showE2EVerification(channelCode = this.currentChannel) {
const partner = this._getE2EPartnerFor(channelCode);
if (!partner || !this.e2e?.ready) {
this._showToast(t('platform.e2e.no_partner_key'), 'error');
return;
}
try {
const code = await this.e2e.getVerificationCode(this.e2e.publicKeyJwk, partner.publicKeyJwk);
const ch = this.channels.find(c => c.code === channelCode);
const partnerName = ch?.dm_target?.username || t('platform.e2e.partner');
let overlay = document.getElementById('e2e-verify-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'e2e-verify-overlay';
overlay.className = 'modal-overlay';
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.style.display = 'none';
});
}
overlay.innerHTML = `
<div class="modal" style="max-width:420px;text-align:center">
<h3 style="margin-bottom:8px">🔐 ${t('header.verify_encryption')}</h3>
<p style="font-size:0.8125rem;color:var(--text-muted);margin-bottom:16px">
${t('modals.e2e_verify.desc', { name: this._escapeHtml(partnerName) })}
</p>
<div class="e2e-safety-number" style="font-family:monospace;font-size:1.125rem;letter-spacing:2px;line-height:2;padding:16px;background:var(--bg-secondary);border-radius:var(--radius-md);border:1px solid var(--border);user-select:all;word-break:break-all">${code}</div>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:center">
<button class="btn-sm btn-accent" id="e2e-copy-code-btn">${t('modals.e2e_verify.copy_btn')}</button>
<button class="btn-sm" id="e2e-close-verify-btn">${t('modals.common.close')}</button>
</div>
</div>
`;
overlay.querySelector('#e2e-copy-code-btn').addEventListener('click', () => {
const markCopied = () => { overlay.querySelector('#e2e-copy-code-btn').textContent = t('common.copied'); };
navigator.clipboard.writeText(code).then(markCopied).catch(() => {
try {
const ta = document.createElement('textarea');
ta.value = code;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.appendChild(ta);
ta.focus(); ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
markCopied();
} catch { /* could not copy */ }
});
});
overlay.querySelector('#e2e-close-verify-btn').addEventListener('click', () => {
overlay.style.display = 'none';
});
overlay.style.display = 'flex';
} catch (err) {
this._showToast(t('platform.e2e.verification_failed'), 'error');
console.error('[E2E] Verification error:', err);
}
},
/**
* Show a scary confirmation popup before resetting E2E encryption keys.
*/
_showE2EResetConfirmation() {
// _requireE2E ensures E2E is ready before calling this
let overlay = document.getElementById('e2e-reset-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'e2e-reset-overlay';
overlay.className = 'modal-overlay';
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.style.display = 'none';
});
}
overlay.innerHTML = `
<div class="modal e2e-reset-modal">
<h3>⚠️ ${t('header.reset_encryption')}</h3>
<div class="e2e-reset-warning">
${t('modals.e2e_reset.warning_irreversible')}
<ul>
<li>${t('modals.e2e_reset.li_new_keys')}</li>
<li>${t('modals.e2e_reset.li_unreadable')}</li>
<li>${t('modals.e2e_reset.li_reverify')}</li>
</ul>
<br>
${t('modals.e2e_reset.warning_permanent')}
</div>
<div class="e2e-confirm-type">
<p style="font-size:0.8125rem;color:var(--text-muted);margin-bottom:8px">${t('modals.e2e_reset.type_confirm')}</p>
<input type="text" id="e2e-reset-confirm-input" placeholder="${t('modals.e2e_reset.confirm_placeholder')}" autocomplete="off" spellcheck="false">
</div>
<div class="e2e-reset-actions">
<button class="btn-danger" id="e2e-reset-confirm-btn">${t('modals.e2e_reset.confirm_btn')}</button>
<button class="btn-sm" id="e2e-reset-cancel-btn">${t('modals.common.cancel')}</button>
</div>
</div>
`;
const confirmInput = overlay.querySelector('#e2e-reset-confirm-input');
const confirmBtn = overlay.querySelector('#e2e-reset-confirm-btn');
confirmInput.addEventListener('input', () => {
if (confirmInput.value.trim().toUpperCase() === 'RESET') {
confirmBtn.classList.add('enabled');
} else {
confirmBtn.classList.remove('enabled');
}
});
confirmBtn.addEventListener('click', async () => {
if (confirmInput.value.trim().toUpperCase() !== 'RESET') return;
overlay.style.display = 'none';
await this._performE2EKeyReset();
});
overlay.querySelector('#e2e-reset-cancel-btn').addEventListener('click', () => {
overlay.style.display = 'none';
});
overlay.style.display = 'flex';
setTimeout(() => confirmInput.focus(), 50);
},
/**
* Actually reset E2E keys, re-publish, and post a notice in chat.
* This must work even when E2E can't initialize (e.g. server backup
* encrypted with old password). Reset generates fresh keys from scratch.
*/
async _performE2EKeyReset() {
// We need the wrapping key from memory, sessionStorage, or password prompt.
let wrappingKey = this._e2eWrappingKey || sessionStorage.getItem('haven_e2e_wrap') || null;
if (!wrappingKey) {
// Wrapping key was cleared after init, so prompt for password directly,
// then retry the reset (no need to show RESET confirmation again).
// Use a custom pending action that bypasses _requireE2E.
this._e2ePwPendingAction = null; // clear normal pending action
this._e2eResetPending = true;
this._showE2EPasswordModal();
return;
}
// Ensure we have an E2E instance (may be null if init failed earlier)
if (!this.e2e) {
if (typeof HavenE2E !== 'undefined') {
this.e2e = new HavenE2E();
await this.e2e._openDB();
} else {
this._showToast(t('platform.e2e.module_unavailable'), 'error');
return;
}
}
try {
const ok = await this.e2e.resetKeys(this.socket, wrappingKey);
if (!ok) {
this._showToast(t('platform.e2e.reset_failed'), 'error');
return;
}
// Re-publish the new public key (force overwrite)
await this.e2e.publishKey(this.socket, true);
// Clear all cached partner shared keys
this._dmPublicKeys = {};
// Post a timestamped notice in the current chat
this._appendE2ENotice(t('platform.e2e.keys_reset_notice', { date: this._fmtDateTime(new Date()) }));
this._showToast(t('platform.e2e.keys_reset'), 'success');
console.log('[E2E] Keys reset by user');
} catch (err) {
console.error('[E2E] Key reset error:', err);
this._showToast(t('platform.e2e.reset_failed_detail', { error: err.message }), 'error');
}
},
/**
* Append a styled E2E system notice to the chat.
*/
_appendE2ENotice(text) {
const container = document.getElementById('messages');
const wasAtBottom = this._coupledToBottom;
const el = document.createElement('div');
el.className = 'system-message e2e-notice';
el.textContent = text;
container.appendChild(el);
if (wasAtBottom) this._scrollToBottom(true);
},
/**
* Decrypt E2E-encrypted messages in place.
* Both sides derive the same ECDH shared secret.
*/
async _decryptMessages(messages, channelCode = null) {
if (!messages || !messages.length) return;
const ch = this.channels.find(c => c.code === (channelCode || this.currentChannel));
if (ch && ch.is_dm && ch.is_group) return this.e2e?.ready ? this._decryptGroupMessages(messages, ch) : messages.forEach(m => { if (m.user_id && m.content && m.type !== 'notice') m.content = t('groups.no_key'); });
if (!this.e2e || !this.e2e.ready) return;
if (!ch || !ch.is_dm || !ch.dm_target) return;
const partnerId = ch.dm_target.id;
const partnerJwk = this._dmPublicKeys[partnerId];
for (const msg of messages) {
if (HavenE2E.isEncrypted(msg.content)) {
if (!partnerJwk) {
msg.content = t('platform.e2e.waiting_for_key');
msg._e2e = true;
continue;
}
const plain = await this.e2e.decrypt(msg.content, partnerId, partnerJwk);
if (plain !== null) {
msg.content = plain;
msg._e2e = true;
} else {
msg.content = t('platform.e2e.unable_to_decrypt');
msg._e2e = true;
}
}
// This is the only place the attachment URLs of an encrypted DM are ever
// in the clear. Note them now so deleting the message can tell the server
// which files to remove. (#5487)
this._rememberDmAttachments?.(msg);
// Also decrypt the reply preview text if the replied-to message was encrypted
if (msg.replyContext && msg.replyContext.content && HavenE2E.isEncrypted(msg.replyContext.content)) {
if (!partnerJwk) {
msg.replyContext.content = t('platform.e2e.waiting_for_key');
} else {
const rplain = await this.e2e.decrypt(msg.replyContext.content, partnerId, partnerJwk);
msg.replyContext.content = rplain !== null ? rplain : t('platform.e2e.unable_to_decrypt');
}
}
}
},
/**
* Wire up download buttons on e2e-file-pending attachments inside `root`.
* Click → fetch encrypted blob → decrypt with the DM partner key →
* trigger a save-as via an object URL. Marks the row `e2e-file-failed` if the
* partner key isn't available so the user understands why download is blocked
* instead of getting a silent no-op. (#5310, #5308)
*/
_decryptE2EFiles(root) {
if (!root) root = document.getElementById('messages');
if (!root) return;
const rows = root.querySelectorAll('.e2e-file-pending');
if (!rows.length) return;
const inPip = !!(root.id === 'dm-pip-messages' || (root.closest && root.closest('#dm-pip-messages')));
const partner = inPip && this._activeDMPip
? this._getE2EPartnerFor(this._activeDMPip)
: this._getE2EPartner();
rows.forEach(row => {
row.classList.remove('e2e-file-pending');
const url = row.dataset.e2eUrl;
const mime = row.dataset.e2eMime || 'application/octet-stream';
const name = row.dataset.e2eName || 'file';
const btn = row.querySelector('.e2e-file-download');
if (!url || !url.startsWith('/uploads/') || !partner) {
row.classList.add('e2e-file-failed');
if (btn) btn.disabled = true;
return;
}
if (!btn) return;
btn.addEventListener('click', async (e) => {
e.preventDefault();
if (btn.disabled) return;
btn.disabled = true;
row.classList.add('e2e-file-loading');
try {
const resp = await fetch(url);
if (!resp.ok) throw new Error(resp.status);
const buf = await resp.arrayBuffer();
const plain = await this._e2eDecryptBytes(partner, new Uint8Array(buf));
const blob = new Blob([plain], { type: mime });
const objectUrl = URL.createObjectURL(blob);
// Video/audio types get an inline player instead of a silent download
const isVideo = mime.startsWith('video/');
const isAudio = mime.startsWith('audio/');
if (isVideo || isAudio) {
const mediaEl = document.createElement(isVideo ? 'video' : 'audio');
mediaEl.controls = true;
mediaEl.preload = 'metadata';
mediaEl.src = objectUrl;
mediaEl.className = isVideo ? 'file-video' : 'file-audio';
// The click that decrypted a voice message was a play click (#5665).
if (isAudio && /^voice-message/i.test(name)) mediaEl.autoplay = true;
row.classList.remove('e2e-file-loading');
row.innerHTML = '';
// Info bar matching the non-E2E file-attachment header style
const infoBar = document.createElement('div');
infoBar.className = 'file-info';
const icon = isVideo ? '🎬' : '🎵';
const nameSafe = name.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
infoBar.innerHTML = `${icon} <span class="file-name">${nameSafe}</span>`;
const dlBtn = document.createElement('a');
dlBtn.href = objectUrl;
dlBtn.download = name;
dlBtn.className = 'file-download-link';
dlBtn.title = t('platform.e2e.download_file', { name });
dlBtn.innerHTML = '⬇';
infoBar.appendChild(dlBtn);
row.appendChild(infoBar);
if (isVideo) {
const wrap = document.createElement('div');
wrap.className = 'file-video-wrap';
wrap.appendChild(mediaEl);
row.appendChild(wrap);
} else {
row.appendChild(mediaEl);
}
// Revoke blob URL when the media element is removed from DOM
const obs = new MutationObserver(() => {
if (!document.contains(mediaEl)) { URL.revokeObjectURL(objectUrl); obs.disconnect(); }
});
obs.observe(document.body, { childList: true, subtree: true });
// Same undecodable-container fallback the plaintext path gets: the
// decrypt succeeded, so the file is fine; the browser just can't play
// it. Disconnect the revoke observer first, since the fallback pulls
// mediaEl out of the DOM and would otherwise kill the blob URL the
// download link is about to point at.
mediaEl.addEventListener('error', () => {
obs.disconnect();
this._fallbackToDownload(mediaEl);
}, { once: true });
btn.disabled = false;
return;
}
// Non-media: trigger download and notify user
const a = document.createElement('a');
a.href = objectUrl;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
this._showToast(t('platform.e2e.downloaded', { name }), 'success');
} catch (err) {
row.classList.add('e2e-file-failed');
} finally {
row.classList.remove('e2e-file-loading');
btn.disabled = false;
}
});
});
},
/**
* Find all e2e-img-pending images in a DOM element (or the messages container),
* fetch their encrypted data, decrypt, and display as blob URLs.
*/
_decryptE2EImages(root) {
if (!root) root = document.getElementById('messages');
if (!root) return;
const imgs = root.querySelectorAll('img.e2e-img-pending');
if (!imgs.length) return;
// If the root is inside the DM PiP overlay, derive the partner from the
// active PiP channel rather than the currently-focused main channel.
const inPip = !!(root.id === 'dm-pip-messages' || (root.closest && root.closest('#dm-pip-messages')));
const partner = inPip && this._activeDMPip
? this._getE2EPartnerFor(this._activeDMPip)
: this._getE2EPartner();
if (!partner) return;
imgs.forEach(img => {
img.classList.remove('e2e-img-pending');
img.classList.add('e2e-img-loading');
const url = img.dataset.e2eSrc;
// Only fetch local upload paths to prevent SSRF
if (!url || !url.startsWith('/uploads/')) {
img.alt = t('platform.e2e.invalid_image_url');
img.classList.remove('e2e-img-loading');
img.classList.add('e2e-img-failed');
return;
}
this._e2eImageBlob(img, partner)
.then(blob => {
// Hand the blob back once the browser has decoded it. Without this the
// object URL keeps the decrypted bytes alive for the life of the tab,
// so scrolling a media-heavy DM slowly locks up hundreds of MB. Same
// revoke-on-load pattern the upload previews use. (#5426)
const objectUrl = URL.createObjectURL(blob);
img.src = objectUrl;
// Revoking in the load handler blanks the picture on some Tauri
// webviews (the bitmap is still tied to the blob URL). Keep it
// a minute, same as decrypted file attachments.
setTimeout(() => { try { URL.revokeObjectURL(objectUrl); } catch { /* already revoked */ } }, 60_000);
img.classList.remove('e2e-img-loading');
})
.catch(() => {
img.alt = t('platform.e2e.image_decrypt_failed');
img.classList.remove('e2e-img-loading');
img.classList.add('e2e-img-failed');
});
});
},
/** The DM partner whose key decrypts media under `node`: the PiP's partner
* when the node lives in the PiP, otherwise the open DM's. */
_e2ePartnerForNode(node) {
const inPip = !!(node && node.closest && node.closest('#dm-pip-messages'));
return inPip && this._activeDMPip
? this._getE2EPartnerFor(this._activeDMPip)
: this._getE2EPartner();
},
/** Fetch and decrypt one E2E image to a Blob. The feed uses it to paint, and
* the lightbox uses it again on click, because the feed's object URL is
* revoked as soon as the image has painted (#5426) and a second look needs
* a second decrypt rather than the bytes kept alive on every node. (#5568) */
_e2eImageBlob(img, partner = null) {
const url = img && img.dataset ? img.dataset.e2eSrc : '';
const mime = (img && img.dataset && img.dataset.e2eMime) || 'image/png';
if (!partner) partner = this._e2ePartnerForNode(img);
if (!partner || !url || !url.startsWith('/uploads/')) return Promise.reject(new Error('not decryptable here'));
return fetch(url)
.then(r => { if (!r.ok) throw new Error(r.status); return r.arrayBuffer(); })
.then(buf => this._e2eDecryptBytes(partner, new Uint8Array(buf)))
.then(plain => new Blob([plain], { type: mime }));
},
};