mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-29 01:55:29 +00:00
2054 lines
88 KiB
JavaScript
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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 }));
|
|
},
|
|
|
|
};
|