mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-28 17:47:30 +00:00
924 lines
45 KiB
JavaScript
924 lines
45 KiB
JavaScript
// The socket connection: setting up every listener (_setupSocketListeners
|
|
// calls each area's _listen method in turn), connecting, reconnecting and
|
|
// waking from sleep, and keeping channel codes in step when they rotate.
|
|
|
|
export default {
|
|
|
|
_migrateChannelCodeState(oldCode, newCode) {
|
|
if (!oldCode || !newCode || oldCode === newCode) return;
|
|
const migrateObjectKey = store => {
|
|
if (!store || !Object.prototype.hasOwnProperty.call(store, oldCode)) return false;
|
|
store[newCode] = store[oldCode];
|
|
delete store[oldCode];
|
|
return true;
|
|
};
|
|
migrateObjectKey(this.unreadCounts);
|
|
migrateObjectKey(this.voiceCounts);
|
|
migrateObjectKey(this.voiceChannelUsers);
|
|
migrateObjectKey(this._pinnedCountByChannel);
|
|
migrateObjectKey(this._unreadPinIdByChannel);
|
|
if (migrateObjectKey(this._threadMentions)) this._persistThreadMentions?.();
|
|
|
|
for (const property of [
|
|
'currentChannel',
|
|
'_lastVoiceUsersChannel',
|
|
'_pendingChannelHistoryCode',
|
|
'_pinsPipChannelCode',
|
|
'_organizeParentCode'
|
|
]) {
|
|
if (this[property] === oldCode) this[property] = newCode;
|
|
}
|
|
if (this.voice?.currentChannel === oldCode) this.voice.currentChannel = newCode;
|
|
if (this.voice?._softLeftChannel === oldCode) this.voice._softLeftChannel = newCode;
|
|
if (this.voice?._joiningChannelCode === oldCode) this.voice._joiningChannelCode = newCode;
|
|
for (const channel of this.channels || []) {
|
|
if (channel.afk_sub_code === oldCode) channel.afk_sub_code = newCode;
|
|
}
|
|
const createSubModal = document.getElementById('create-sub-modal');
|
|
if (createSubModal?._parentCode === oldCode) createSubModal._parentCode = newCode;
|
|
|
|
try {
|
|
if (localStorage.getItem('haven_voice_channel') === oldCode) {
|
|
localStorage.setItem('haven_voice_channel', newCode);
|
|
}
|
|
} catch { /* storage blocked: the saved voice channel keeps the old code and simply will not auto-rejoin */ }
|
|
for (const key of ['haven_muted_channels', 'haven_hidden_channels']) {
|
|
try {
|
|
const values = JSON.parse(localStorage.getItem(key) || '[]');
|
|
if (!Array.isArray(values) || !values.includes(oldCode)) continue;
|
|
const migrated = values.map(code => code === oldCode ? newCode : code);
|
|
localStorage.setItem(key, JSON.stringify([...new Set(migrated)]));
|
|
} catch { /* storage blocked or corrupt list: this preference keeps the old code */ }
|
|
}
|
|
try {
|
|
const moveStorageKey = (oldKey, newKey) => {
|
|
const value = localStorage.getItem(oldKey);
|
|
if (value === null) return;
|
|
localStorage.setItem(newKey, value);
|
|
localStorage.removeItem(oldKey);
|
|
};
|
|
for (const prefix of [
|
|
'haven_seen_pin_max_',
|
|
'haven_tag_sorts_',
|
|
'haven_cat_order_',
|
|
'haven_cat_sort_',
|
|
'haven_subs_collapsed_'
|
|
]) moveStorageKey(`${prefix}${oldCode}`, `${prefix}${newCode}`);
|
|
|
|
const subtagPrefix = `haven_subtag_collapsed_${oldCode}_`;
|
|
const subtagKeys = [];
|
|
for (let index = 0; index < localStorage.length; index++) {
|
|
const key = localStorage.key(index);
|
|
if (key?.startsWith(subtagPrefix)) subtagKeys.push(key);
|
|
}
|
|
for (const key of subtagKeys) {
|
|
moveStorageKey(key, `haven_subtag_collapsed_${newCode}_${key.slice(subtagPrefix.length)}`);
|
|
}
|
|
} catch { /* localStorage may be unavailable */ }
|
|
},
|
|
|
|
_collectChannelCodeRotations(channels) {
|
|
const rotations = new Map();
|
|
const persistedCodes = this._readChannelCodeMap();
|
|
for (const channel of channels) {
|
|
const oldCode = persistedCodes[channel.id];
|
|
if (typeof oldCode === 'string' && oldCode !== channel.code) {
|
|
rotations.set(oldCode, { channelId: channel.id, oldCode, newCode: channel.code });
|
|
}
|
|
}
|
|
for (const previous of this.channels || []) {
|
|
const updated = channels.find(channel => channel.id === previous.id);
|
|
if (updated && updated.code !== previous.code) {
|
|
rotations.set(previous.code, {
|
|
channelId: previous.id,
|
|
oldCode: previous.code,
|
|
newCode: updated.code
|
|
});
|
|
}
|
|
}
|
|
return [...rotations.values()];
|
|
},
|
|
|
|
_readChannelCodeMap() {
|
|
const ownerId = this.user?.id;
|
|
if (ownerId == null) return {};
|
|
try {
|
|
const persisted = JSON.parse(localStorage.getItem('haven_channel_codes_by_id') || 'null');
|
|
if (String(persisted?.ownerId) !== String(ownerId) || !persisted?.codes || typeof persisted.codes !== 'object' || Array.isArray(persisted.codes)) {
|
|
return {};
|
|
}
|
|
return persisted.codes;
|
|
} catch {
|
|
return {};
|
|
}
|
|
},
|
|
|
|
_persistChannelCodeMap(channels) {
|
|
const ownerId = this.user?.id;
|
|
if (ownerId == null) return;
|
|
const codes = {};
|
|
for (const channel of channels || []) {
|
|
if (channel?.id != null && channel.code) codes[channel.id] = channel.code;
|
|
}
|
|
try {
|
|
localStorage.setItem('haven_channel_codes_by_id', JSON.stringify({
|
|
ownerId: String(ownerId),
|
|
codes
|
|
}));
|
|
} catch { /* localStorage may be unavailable */ }
|
|
},
|
|
|
|
_updatePersistedChannelCode(channelId, code) {
|
|
if (channelId == null || !code) return;
|
|
const codes = this._readChannelCodeMap();
|
|
codes[channelId] = code;
|
|
this._persistChannelCodeMap(Object.entries(codes).map(([id, channelCode]) => ({
|
|
id,
|
|
code: channelCode
|
|
})));
|
|
},
|
|
|
|
_clearChannelCodeMap() {
|
|
try {
|
|
localStorage.removeItem('haven_channel_codes_by_id');
|
|
} catch { /* localStorage may be unavailable */ }
|
|
},
|
|
|
|
// ── Socket Event Listeners ────────────────────────────
|
|
|
|
// A message refused for being too long used to vanish: the box clears on
|
|
// send. Put the text back so it can be trimmed, unless something new has
|
|
// been typed since or the channel changed (#5691). The same for a
|
|
// self-destructing message the sender may not send: the text comes back
|
|
// so it can go as a normal one, by choice.
|
|
// A new account told to wait before posting gets its text back too (#5742).
|
|
_restoreRefusedDraft(msg, always = false) {
|
|
const d = this._lastSendDraft;
|
|
const tooLong = typeof msg === 'string' && /^Message too long/.test(msg);
|
|
const sdRefused = typeof msg === 'string' && /self-destructing messages$/.test(msg);
|
|
if (!d || (!tooLong && !sdRefused && !always)) return;
|
|
this._lastSendDraft = null;
|
|
const inputId = d.inputId || 'message-input';
|
|
const open = inputId === 'dm-pip-input' ? this._activeDMPip
|
|
: inputId === 'thread-input' ? this._activeThreadParent
|
|
: this.currentChannel;
|
|
if (Date.now() - d.at > 15000 || d.code !== open) return;
|
|
const input = document.getElementById(inputId);
|
|
if (!input || input.value.trim()) return;
|
|
input.value = d.text;
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
// A too-long self-destructing message keeps its timer on for the resend.
|
|
if (tooLong && d.selfDestruct && inputId === 'message-input') this._setSelfDestructArmed?.(true);
|
|
input.focus();
|
|
},
|
|
|
|
_setupSocketListeners() {
|
|
// Each area wires its own part, in this order.
|
|
this._listenSession();
|
|
this._listenChannelsAndMessages();
|
|
this._listenPresenceAndVoice();
|
|
this._listenFeatureEvents();
|
|
this._listenMessageChanges();
|
|
this._listenAdminAndPrefs();
|
|
},
|
|
|
|
_listenSession() {
|
|
this._setupFerrySocket();
|
|
this._setupCallListeners?.();
|
|
this._setupGroupListeners?.();
|
|
// Authoritative user info pushed by server on every connect
|
|
this.socket.on('session-info', (data) => {
|
|
this.user = { ...this.user, ...data };
|
|
this.user.roles = data.roles || [];
|
|
this.user.effectiveLevel = data.effectiveLevel || 0;
|
|
this.user.permissions = data.permissions || [];
|
|
this.user.globalPermissions = data.globalPermissions || [];
|
|
this._renderE2EPassphraseSection?.();
|
|
if (this.voice && data.id) this.voice.localUserId = data.id;
|
|
if (data.status) {
|
|
this.userStatus = data.status;
|
|
this.userStatusText = data.statusText || '';
|
|
this._manualStatusOverride = (data.status !== 'online' && data.status !== 'away');
|
|
this._updateStatusPickerUI();
|
|
}
|
|
// Sync avatar shape from server
|
|
if (data.avatarShape) {
|
|
this.user.avatarShape = data.avatarShape;
|
|
this._avatarShape = data.avatarShape;
|
|
this._pendingAvatarShape = data.avatarShape;
|
|
localStorage.setItem('haven_avatar_shape', data.avatarShape);
|
|
// Update shape picker UI
|
|
const picker = document.getElementById('avatar-shape-picker');
|
|
if (picker) {
|
|
picker.querySelectorAll('.avatar-shape-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.shape === data.avatarShape);
|
|
});
|
|
}
|
|
}
|
|
// Sync border (pfp overlay) from server, authoritative like avatar shape
|
|
if (data.border !== undefined) {
|
|
this.user.border = data.border || null;
|
|
}
|
|
// Sync the border fit (op log) so the editor restores it and pfps render it
|
|
if (data.borderTransform !== undefined) {
|
|
this.user.borderTransform = Array.isArray(data.borderTransform) ? data.borderTransform : null;
|
|
}
|
|
// Sync the animation policy (trigger/disabled) so the editor seeds it and pfps honor it
|
|
if (data.animateProfile !== undefined) {
|
|
this.user.animateProfile = data.animateProfile === 'disabled' ? 'disabled' : 'trigger';
|
|
}
|
|
localStorage.setItem('haven_user', JSON.stringify(this.user));
|
|
// (#5394) Server-stored nicknames are the record. localStorage is only a
|
|
// cache for the first paint before this event lands. The old merge also
|
|
// pushed any localStorage-only nickname back up on every connect, so a
|
|
// nickname cleared from one device came back from any other device that
|
|
// still had it cached, and could never be removed for good. (#5560)
|
|
if (data.nicknames && typeof data.nicknames === 'object') {
|
|
this._nicknames = { ...data.nicknames };
|
|
localStorage.setItem('haven_nicknames', JSON.stringify(this._nicknames));
|
|
}
|
|
// Init E2E encryption AFTER socket is fully connected & server handlers registered
|
|
if (!this._e2eInitDone) {
|
|
this._e2eInitDone = true;
|
|
this._initE2E();
|
|
}
|
|
// Show server version in status bar
|
|
if (data.version) {
|
|
const vEl = document.getElementById('status-version');
|
|
if (vEl) vEl.textContent = 'v' + data.version;
|
|
}
|
|
// Refresh display name + admin UI with authoritative data
|
|
document.getElementById('current-user').textContent = this.user.displayName || this.user.username;
|
|
const loginEl = document.getElementById('login-name');
|
|
if (loginEl) loginEl.textContent = `@${this.user.username}`;
|
|
// Update avatar preview in settings if present
|
|
this._updateAvatarPreview();
|
|
this._updateBorderPreview();
|
|
// Show admin/mod controls based on role level
|
|
const canModerate = this.user.isAdmin || this.user.effectiveLevel >= 25;
|
|
const canCreateChannel = this.user.isAdmin || this._hasGlobalPerm('create_channel');
|
|
document.getElementById('admin-controls').style.display = canCreateChannel ? 'block' : 'none';
|
|
document.getElementById('admin-mod-panel').style.display = (canModerate || this._hasAnyAdminSettingsAccess()) ? 'block' : 'none';
|
|
document.getElementById('sidebar-members-btn').style.display = (this.user.isAdmin || canModerate || this._hasPerm('view_all_members') || this._hasPerm('view_channel_members')) ? '' : 'none';
|
|
});
|
|
|
|
// Roles updated (from admin assigning/revoking, or editing a role we hold)
|
|
this.socket.on('roles-updated', (data) => {
|
|
this._refreshMentionableRoles?.();
|
|
// The server also fires this with NO payload as a plain "the server's role
|
|
// list changed" nudge (role edited, roles reset, admin role display
|
|
// changed) for anyone with the Role Management modal open. There's no
|
|
// per-user permission set to apply then. Bail instead of throwing on
|
|
// `data.roles`. That TypeError also aborted every roles-updated listener
|
|
// registered after this one, including the modal's own _loadRoles refresh.
|
|
if (!data) return;
|
|
// Your own roles/permissions just changed, so cached search results may now
|
|
// include messages you can no longer access. Force-invalidate the panel
|
|
// (the channel list may be unchanged, so the signature check won't catch
|
|
// this). Payload-less server-wide nudges bail above and don't trigger it.
|
|
this._searchMarkStale?.();
|
|
this.user.roles = data.roles || [];
|
|
this.user.effectiveLevel = data.effectiveLevel || 0;
|
|
this.user.permissions = data.permissions || [];
|
|
this.user.globalPermissions = data.globalPermissions || [];
|
|
localStorage.setItem('haven_user', JSON.stringify(this.user));
|
|
// Refresh UI to reflect new permissions
|
|
const canModerate = this.user.isAdmin || this.user.effectiveLevel >= 25;
|
|
const canCreateChannel = this.user.isAdmin || this._hasGlobalPerm('create_channel');
|
|
const canCreateInvites = this.user.isAdmin || this._hasGlobalPerm('manage_server') || this._hasGlobalPerm('invite_users');
|
|
document.getElementById('admin-controls').style.display = canCreateChannel ? 'block' : 'none';
|
|
document.getElementById('admin-mod-panel').style.display = (canModerate || this._hasAnyAdminSettingsAccess()) ? 'block' : 'none';
|
|
document.getElementById('sidebar-members-btn').style.display = (this.user.isAdmin || canModerate || this._hasPerm('view_all_members') || this._hasPerm('view_channel_members')) ? '' : 'none';
|
|
document.getElementById('sidebar-invite-panel').style.display = canCreateInvites ? 'block' : 'none';
|
|
this._showToast(t('toasts.roles_updated'), 'info');
|
|
});
|
|
|
|
// Avatar updated confirmation (from socket broadcast by other tabs/reconnect)
|
|
this.socket.on('avatar-updated', (data) => {
|
|
if (data && data.url !== undefined) {
|
|
this.user.avatar = data.url;
|
|
localStorage.setItem('haven_user', JSON.stringify(this.user));
|
|
this._updateAvatarPreview();
|
|
}
|
|
});
|
|
|
|
// (#5426) A custom sound/emoji/sticker was added or removed by an admin.
|
|
// Re-fetch the affected library so the change shows up live for everyone
|
|
// instead of only after an app restart.
|
|
this.socket.on('library-updated', (data) => {
|
|
const kind = data && data.kind;
|
|
if (kind === 'sounds') this._loadCustomSounds?.();
|
|
else if (kind === 'emojis') this._loadCustomEmojis?.();
|
|
else if (kind === 'stickers') this._loadStickers?.();
|
|
});
|
|
|
|
this.socket.on('connect', () => {
|
|
this._setLed('connection-led', 'on');
|
|
this._setLed('status-server-led', 'on');
|
|
document.getElementById('status-server-text').textContent = t('app.status.connected');
|
|
this._lastConnectTime = Date.now();
|
|
this._authErrorStreak = 0;
|
|
this._startPingMonitor();
|
|
// (#self-absent-voice-panel) Cancel any pending soft-leave from a brief
|
|
// socket blip: we reconnected before the 2 s deadline, so the voice
|
|
// session is still live and just needs to rebind its socketId on the
|
|
// server side via voice-rejoin (handled below).
|
|
if (this._voiceDisconnectTimer) {
|
|
clearTimeout(this._voiceDisconnectTimer);
|
|
this._voiceDisconnectTimer = null;
|
|
}
|
|
if (this._savedVoiceRejoinTimer) {
|
|
clearTimeout(this._savedVoiceRejoinTimer);
|
|
this._savedVoiceRejoinTimer = null;
|
|
}
|
|
// A reconnect usually means the machine slept or the network dropped, which
|
|
// is the most likely moment for the media token to have expired underneath
|
|
// us. Cheap to redo and it keeps remote images from silently breaking.
|
|
this._refreshMediaToken?.();
|
|
|
|
// Re-join channel after reconnect (server lost our room membership)
|
|
this.socket.emit('visibility-change', { visible: !document.hidden });
|
|
this.voice?.deferChannelGone?.(6000);
|
|
this.socket.emit('get-channels');
|
|
this.socket.emit('get-server-settings');
|
|
// Your own theme, effects and other synced settings. Startup asks once;
|
|
// when that first connection drops before the answer arrives (a server
|
|
// still starting after a reboot), the app ran on defaults until a reload
|
|
// (#5723). Asked again only while it is still missing, so a normal
|
|
// reconnect does not reapply the theme under you.
|
|
if (!this._prefsReceived && this._prefsAskedOnce) this.socket.emit('get-preferences');
|
|
this._prefsAskedOnce = true;
|
|
// Role names for @Role mentions: rendering and the @ picker. (#5579)
|
|
this._refreshMentionableRoles?.();
|
|
|
|
// (#5399 follow-up) Reconcile per-channel mute prefs with the server
|
|
// once per session so the server can honor them when fanning out
|
|
// pushes. Guarded internally, so safe to call on every reconnect.
|
|
this._bootstrapChannelPrefs?.();
|
|
|
|
// (#5391) Watchdog: if the socket connects but channels-list never
|
|
// arrives, the session is in a broken state that the auth middleware
|
|
// didn't catch (DB hiccup mid-handshake, getEnrichedChannels throwing,
|
|
// user row out of sync, etc.). The visible symptom is the sidebar
|
|
// sitting empty forever and the user not knowing whether to refresh.
|
|
// After 10 s of silence, fall back to a deterministic HTTP token check
|
|
// and either retry once or kick to /login. Cleared as soon as
|
|
// channels-list lands (see the channels-list handler below).
|
|
if (this._channelsWatchdog) clearTimeout(this._channelsWatchdog);
|
|
this._channelsListGotResponse = false;
|
|
this._channelsWatchdog = setTimeout(async () => {
|
|
if (this._channelsListGotResponse) return;
|
|
try {
|
|
const resp = await fetch('/api/auth/validate', {
|
|
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('haven_token') || '') }
|
|
});
|
|
if (resp.status === 401 || resp.status === 404) {
|
|
// Token is stale or user row gone: same outcome as a socket
|
|
// 'Invalid token' / 'Session expired'. Kick to login.
|
|
this._clearChannelCodeMap();
|
|
localStorage.removeItem('haven_token');
|
|
localStorage.removeItem('haven_user');
|
|
localStorage.removeItem('haven_sync_key');
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
} catch {
|
|
// Network failure: leave it alone, the user can refresh manually.
|
|
return;
|
|
}
|
|
// Token is valid but channels never came. Retry once before giving up.
|
|
if (!this._channelsListGotResponse && this.socket?.connected) {
|
|
console.warn('[#5391] channels-list missing after 10s, retrying get-channels');
|
|
this.socket.emit('get-channels');
|
|
setTimeout(() => {
|
|
if (!this._channelsListGotResponse) {
|
|
// Server clearly can't fulfil get-channels for this session;
|
|
// a full reload picks up any server-side fix and re-runs the
|
|
// auth handshake from scratch.
|
|
console.warn('[#5391] channels-list still missing after retry, forcing reload');
|
|
window.location.reload();
|
|
}
|
|
}, 5000);
|
|
}
|
|
}, 10000);
|
|
if (this.currentChannel) {
|
|
this.socket.emit('enter-channel', { code: this.currentChannel });
|
|
// Reset pagination: reconnect replaces message list
|
|
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 });
|
|
this.socket.emit('get-channel-members', { code: this.currentChannel });
|
|
// Request fresh voice list for the channel currently in view.
|
|
this.socket.emit('request-voice-users', {
|
|
code: this.currentChannel,
|
|
iAmInVoice: !!(this.voice && this.voice.inVoice && this.voice.currentChannel === this.currentChannel)
|
|
});
|
|
}
|
|
// Re-join voice if we were in voice before reconnect
|
|
if (this.voice && this.voice.inVoice && this.voice.currentChannel) {
|
|
this.socket.emit('voice-rejoin', { code: this.voice.currentChannel, ...this.voice.getRelayClientInfo() });
|
|
if (this.voice.isMuted) this.socket.emit('voice-mute-state', { code: this.voice.currentChannel, muted: true });
|
|
if (this.voice.isDeafened) this.socket.emit('voice-deafen-state', { code: this.voice.currentChannel, deafened: true });
|
|
// (#5427) When the socket flaps (common on the web client behind certain
|
|
// proxies/browsers), this fast-path rejoin keeps the existing peer
|
|
// connections instead of rebuilding them, but if ICE silently died
|
|
// during the outage, some peers end up with no audio while others are
|
|
// fine. The auto-recovery on connectionstatechange can take up to 8s and
|
|
// can miss an event that fired while we were disconnected. Once signaling
|
|
// is back, proactively ICE-restart only the peers that are actually
|
|
// broken so audio comes back without a manual leave/rejoin.
|
|
setTimeout(() => {
|
|
if (this.socket?.connected) this.voice._healPeerConnections?.();
|
|
}, 1500);
|
|
} else if (this.voice && this.voice._softLeftChannel) {
|
|
// (#5347 v3.15.4) The socket dropped while we were in voice; _softLeave
|
|
// tore down local audio but kept the channel intent. Re-init the mic
|
|
// and announce ourselves via voice-rejoin so peers tear down their
|
|
// stale RTCPeerConnections via voice-user-left and we get fresh ones.
|
|
// This is the proper rejoin path. The localStorage setTimeout(1500)
|
|
// fallback below uses voice.join which doesn't do that, leaving peers
|
|
// with dead audio paths even after "rejoin".
|
|
const rejoinChannel = this.voice._softLeftChannel;
|
|
this.voice._softLeftChannel = null;
|
|
(async () => {
|
|
try {
|
|
const ok = await this.voice.join(rejoinChannel);
|
|
if (ok) {
|
|
this._updateVoiceButtons(true);
|
|
this._updateVoiceStatus(true);
|
|
this._updateVoiceBar();
|
|
}
|
|
} catch (e) {
|
|
console.warn('[Voice] reconnect rejoin failed:', e);
|
|
}
|
|
})();
|
|
} else {
|
|
// Check localStorage for saved voice channel (persists across page refreshes / server restarts)
|
|
try {
|
|
const savedVoiceChannel = localStorage.getItem('haven_voice_channel');
|
|
if (savedVoiceChannel && /^[a-f0-9]{8}$/i.test(savedVoiceChannel)) {
|
|
// Auto-rejoin saved voice channel after delay (wait for channels to load)
|
|
this._savedVoiceRejoinTimer = setTimeout(async () => {
|
|
this._savedVoiceRejoinTimer = null;
|
|
if (this.voice && !this.voice.inVoice) {
|
|
let currentSavedChannel = savedVoiceChannel;
|
|
try { currentSavedChannel = localStorage.getItem('haven_voice_channel') || savedVoiceChannel; } catch { /* storage blocked (private mode): keep the default */ }
|
|
console.log('[Voice] Auto-rejoining saved voice channel:', currentSavedChannel);
|
|
const ok = await this.voice.join(currentSavedChannel);
|
|
if (ok) {
|
|
this._updateVoiceButtons(true);
|
|
this._updateVoiceStatus(true);
|
|
this._updateVoiceBar();
|
|
}
|
|
}
|
|
}, 1500);
|
|
}
|
|
} catch (err) { console.warn('[Voice] auto-rejoin check failed', err); }
|
|
}
|
|
// Apply any queued status change from when we were disconnected
|
|
if (this._pendingStatus) {
|
|
this.socket.emit('set-status', this._pendingStatus);
|
|
this._pendingStatus = null;
|
|
}
|
|
// Ask the server whether to surface the recovery-codes notice. It decides:
|
|
// skipped when the account already has recovery codes, or the user ticked
|
|
// "never show again" (both checked server-side). Reply is handled by the
|
|
// 'recovery-notice-state' listener registered in _setupSocketListeners.
|
|
this.socket.emit('get-recovery-notice-state');
|
|
});
|
|
document.addEventListener('visibilitychange', () => {
|
|
this.socket?.emit('visibility-change', { visible: !document.hidden });
|
|
// Track when we went hidden so we can detect long sleeps on resume
|
|
// (PC suspend/lock for hours leaves a "zombie" socket that the client
|
|
// thinks is connected but the server has long since dropped via ping
|
|
// timeout. The result is empty member lists and no chat history on
|
|
// wake until you switch channels twice). (#post-sleep-channel-desync)
|
|
if (document.hidden) {
|
|
this._hiddenAt = Date.now();
|
|
// (#5463) Mark the current wake-detector window as tainted (see the
|
|
// background-throttling note on _wakeCheckInterval below.
|
|
this._tabHiddenSinceWakeCheck = true;
|
|
return;
|
|
}
|
|
const hiddenForMs = this._hiddenAt ? (Date.now() - this._hiddenAt) : 0;
|
|
this._hiddenAt = null;
|
|
// Mobile fix: when returning to foreground, ensure socket is connected and refresh data
|
|
if (!document.hidden) {
|
|
// After a long hidden period (>30 s) the socket is almost certainly
|
|
// a zombie even if .connected reports true. Chromium throttles
|
|
// background tabs and macOS/Windows suspend network I/O during
|
|
// sleep. Force a clean reconnect cycle so the 'connect' handler
|
|
// does the full resync (enter-channel, get-messages, members,
|
|
// voice users, voice-rejoin) rather than relying on the partial
|
|
// refresh below.
|
|
//
|
|
// (#5444) Never do that while a live voice session is running,
|
|
// though. Cycling the socket makes the server grace-evict the voice
|
|
// slot and re-add it a moment later, which is heard by everyone in
|
|
// the call as a leave sound followed by a join sound, for nothing
|
|
// more than the user tabbing away for half a minute and coming
|
|
// back. In that case fall through to the in-place refresh below.
|
|
const voiceLive = !!(this.voice && this.voice.inVoice &&
|
|
((this.voice.liveVoicePeerCount?.() || 0) > 0 || this.voice.localStream));
|
|
if (hiddenForMs > 30000 && this.socket && !voiceLive) {
|
|
try { this.socket.disconnect(); } catch (err) { console.warn('[Socket] disconnect before reconnect failed', err); }
|
|
try { this.socket.connect(); } catch (err) { console.warn('[Socket] reconnect failed', err); }
|
|
return;
|
|
}
|
|
if (this.socket && !this.socket.connected) {
|
|
this.socket.connect();
|
|
}
|
|
// Delayed fallback: on some mobile browsers the WebSocket dies moments
|
|
// after the tab resumes rather than before, so the immediate check above
|
|
// might see it as "still connected." Retry a couple of seconds later.
|
|
setTimeout(() => {
|
|
if (this.socket && !this.socket.connected) this.socket.connect();
|
|
}, 2500);
|
|
// Browsers don't compute layout accurately while a tab is hidden, so
|
|
// scrollToBottom during a background reconnect often undershoots.
|
|
// Defer to requestAnimationFrame so the browser recalculates layout
|
|
// before we read scrollHeight; avoids jumping to wrong position.
|
|
if (this._coupledToBottom) {
|
|
this._suppressCoupleCheck = true;
|
|
requestAnimationFrame(() => {
|
|
this._scrollToBottom(true);
|
|
this._suppressCoupleCheck = false;
|
|
});
|
|
}
|
|
|
|
// Skip heavy refresh if we just handled a 'connect' event (avoids doubled emits)
|
|
const sinceLast = Date.now() - (this._lastConnectTime || 0);
|
|
if (sinceLast < 3000) return;
|
|
// Re-fetch current channel messages + member list to catch anything missed
|
|
// Only do a full reset if coupled to bottom; if the user was browsing
|
|
// history before the tab switch, preserve their position by skipping the
|
|
// reset so _renderMessages doesn't yank them to the latest messages.
|
|
if (this.currentChannel && this.socket?.connected) {
|
|
// (#post-sleep-channel-desync) Re-emit enter-channel so the server
|
|
// re-adds this socket to its channelUsers map for this code. Without
|
|
// this, subsequent online-users broadcasts compute the roster from
|
|
// a stale map and the user sees an empty member list, exactly the
|
|
// symptom reported after a multi-hour PC sleep.
|
|
this.socket.emit('enter-channel', { code: this.currentChannel });
|
|
if (this._coupledToBottom) {
|
|
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 });
|
|
}
|
|
this.socket.emit('get-channel-members', { code: this.currentChannel });
|
|
// Pull a fresh online-users + voice roster so the right-side panels
|
|
// aren't stuck on whatever stale snapshot was rendered before sleep.
|
|
this.socket.emit('request-online-users', { code: this.currentChannel });
|
|
this.socket.emit('request-voice-users', {
|
|
code: this.currentChannel,
|
|
iAmInVoice: !!(this.voice && this.voice.inVoice && this.voice.currentChannel === this.currentChannel)
|
|
});
|
|
}
|
|
// (#5444) We deliberately kept the socket alive above if voice was
|
|
// live, so rebind the voice slot here instead. This is a no-op on
|
|
// the server when we're already bound on this socket.
|
|
if (voiceLive && this.voice?.currentChannel && this.socket?.connected) {
|
|
this.socket.emit('voice-rejoin', { code: this.voice.currentChannel, ...this.voice.getRelayClientInfo() });
|
|
}
|
|
// Re-fetch channels in case list changed while backgrounded
|
|
this.socket?.emit('get-channels');
|
|
|
|
// Mobile voice fix: check if we should be in voice but got disconnected
|
|
try {
|
|
const savedVoiceChannel = localStorage.getItem('haven_voice_channel');
|
|
if (savedVoiceChannel && this.voice && !this.voice.inVoice && this.socket?.connected) {
|
|
console.log('[Voice] Mobile foreground, rejoining voice channel:', savedVoiceChannel);
|
|
setTimeout(async () => {
|
|
if (this.voice && !this.voice.inVoice) {
|
|
const ok = await this.voice.join(savedVoiceChannel);
|
|
if (ok) {
|
|
this._updateVoiceButtons(true);
|
|
this._updateVoiceStatus(true);
|
|
this._updateVoiceBar();
|
|
}
|
|
}
|
|
}, 500);
|
|
}
|
|
} catch (err) { console.warn('[Voice] auto-rejoin check failed', err); }
|
|
}
|
|
});
|
|
|
|
// iOS Safari bfcache: page is restored from cache (back/forward nav or tab switch)
|
|
// without a visibilitychange event, so reconnect if the socket is stale.
|
|
window.addEventListener('pageshow', (e) => {
|
|
if (e.persisted && this.socket && !this.socket.connected) {
|
|
this.socket.connect();
|
|
}
|
|
});
|
|
|
|
// ── Wake-from-sleep detector (#post-sleep-channel-desync round 2) ──
|
|
// The visibilitychange-based fix above ONLY catches scenarios where the
|
|
// browser fires a hidden→visible transition. On Windows, locking the PC
|
|
// (Win+L) does NOT hide the window from the browser's perspective. The
|
|
// lock screen is an OS overlay, not a window state change. So after a
|
|
// multi-hour lock, visibilitychange never fires on unlock, and the
|
|
// previous fix never runs. Result: empty member list, empty chat,
|
|
// zombie socket that the client thinks is still connected.
|
|
//
|
|
// Timer drift detection works regardless of visibility: when the OS
|
|
// suspends or throttles JS execution, setInterval ticks pause. On wake,
|
|
// the next tick fires immediately with a huge gap from the previous
|
|
// tick. If that gap exceeds the threshold, we know the process was
|
|
// suspended and the socket is almost certainly a zombie.
|
|
//
|
|
// (#5463 / #5444) BUT: timer drift is ALSO what a backgrounded tab looks
|
|
// like. Once a tab has been hidden and silent for ~5 minutes, Chromium
|
|
// applies "intensive throttling" and fires setInterval at most once per
|
|
// MINUTE. That produced a 60 s drift on every tick, which tripped this
|
|
// detector, which hard-cycled the socket, which grace-evicted the user
|
|
// from voice on the server: a self-inflicted disconnect/reconnect loop
|
|
// running exactly every 60 seconds, forever, for as long as the tab sat
|
|
// in the background. It only ever hit web users because Haven Desktop
|
|
// sets `backgroundThrottling: false` on its windows, and it only ever
|
|
// hit idle tabs because a foreground tab is never throttled.
|
|
//
|
|
// So: only trust drift while the tab is actually visible. A hidden tab
|
|
// has its own recovery path already (the visibilitychange handler above
|
|
// forces a full resync when it comes back after >30 s hidden), and a
|
|
// socket that genuinely died while hidden is handled by socket.io's own
|
|
// reconnection. The PC-lock case this detector exists for is unaffected,
|
|
// because Win+L does NOT mark the page hidden.
|
|
this._lastWakeCheck = Date.now();
|
|
this._tabHiddenSinceWakeCheck = document.hidden;
|
|
if (this._wakeCheckInterval) clearInterval(this._wakeCheckInterval);
|
|
this._wakeCheckInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
const drift = now - this._lastWakeCheck;
|
|
this._lastWakeCheck = now;
|
|
// Discard any interval that the tab spent hidden for even part of its
|
|
// length. The drift is throttling, not a suspend, and we can't tell
|
|
// the two apart from the timestamp alone.
|
|
const wasHidden = document.hidden || this._tabHiddenSinceWakeCheck;
|
|
this._tabHiddenSinceWakeCheck = document.hidden;
|
|
if (wasHidden) return;
|
|
// Threshold: 30 s. Normal tick is 5 s, so even with heavy GC pauses
|
|
// or main-thread blocking we won't false-positive at 30 s.
|
|
if (drift > 30000) {
|
|
console.log(`[wake-detect] resumed after ${Math.round(drift/1000)}s, forcing socket resync`);
|
|
this._forceFullResync('wake-from-sleep');
|
|
}
|
|
}, 5000);
|
|
|
|
// Window focus is another reliable wake signal on Windows: when the user
|
|
// unlocks the PC and clicks back into the Haven window, focus fires even
|
|
// if visibilitychange didn't. We debounce against the wake detector so
|
|
// we don't double-cycle the socket within a few seconds.
|
|
//
|
|
// CRITICAL: title-bar drag / double-click maximize also fires `focus`,
|
|
// often during a multi-second main-thread stall while the stream video
|
|
// element relayouts. A single missed pong used to hard-cycle the socket
|
|
// (`focus-zombie`), which is exactly the "first resize drops me from
|
|
// voice roster but I can still talk" bug: server grace-evicts the old
|
|
// socketId, UI flips to Join Voice, WebRTC peers keep carrying audio.
|
|
window.addEventListener('resize', () => {
|
|
this._recentWindowResizeAt = Date.now();
|
|
});
|
|
window.addEventListener('focus', () => {
|
|
const sinceLastResync = Date.now() - (this._lastForcedResync || 0);
|
|
if (sinceLastResync < 5000) return;
|
|
// Ignore focus storms that accompany a window resize/maximize.
|
|
if (this._recentWindowResizeAt && (Date.now() - this._recentWindowResizeAt) < 2500) {
|
|
return;
|
|
}
|
|
// Cheap liveness probe: emit a ping-check and force-resync if no pong
|
|
// arrives. Avoids needlessly cycling the socket on a quick window-switch
|
|
// where the connection is actually fine.
|
|
if (!this.socket?.connected) {
|
|
this._forceFullResync('focus-disconnected');
|
|
return;
|
|
}
|
|
// In an active voice session, never hard-disconnect on one missed pong.
|
|
// Stream decode + layout on maximize routinely blocks the renderer long
|
|
// enough for a 4s timer to fire even though the socket is healthy.
|
|
const voiceLive = !!(this.voice && (this.voice.inVoice || this.voice.liveVoicePeerCount?.() > 0));
|
|
const probeStart = Date.now();
|
|
let acked = false;
|
|
const ackHandler = () => { acked = true; };
|
|
this.socket.once('pong-check', ackHandler);
|
|
// Route through _pingSend so this probe's send time is queued too. It is a
|
|
// real round trip and should show up as one; emitting 'ping-check' directly
|
|
// here is what desynced the queue-less reading.
|
|
try { this._pingSend(); } catch { /* a ping that never goes out shows up as a missed reading below */ }
|
|
const probeMs = voiceLive ? 10000 : 6000;
|
|
setTimeout(() => {
|
|
this.socket?.off('pong-check', ackHandler);
|
|
if (acked) return;
|
|
if (voiceLive) {
|
|
// Light recovery only: rebind voice + refresh rosters. Do NOT
|
|
// disconnect; that is what knocks us out of the server voice map
|
|
// while leaving WebRTC audio running.
|
|
console.warn(`[wake-detect] no pong in ${probeMs}ms on focus while in voice (probe ${Date.now()-probeStart}ms), light resync, not disconnect`);
|
|
this._lightVoiceResync('focus-zombie-in-voice');
|
|
return;
|
|
}
|
|
console.log(`[wake-detect] no pong in ${probeMs}ms on focus (probe ${Date.now()-probeStart}ms), zombie socket, forcing resync`);
|
|
this._forceFullResync('focus-zombie');
|
|
}, probeMs);
|
|
});
|
|
|
|
this.socket.on('disconnect', () => {
|
|
this._setLed('connection-led', 'danger pulse');
|
|
this._setLed('status-server-led', 'danger pulse');
|
|
document.getElementById('status-server-text').textContent = t('app.status.disconnected');
|
|
document.getElementById('status-ping').textContent = '--';
|
|
// Drop outstanding probes. Pairing one with a pong from after the
|
|
// reconnect would report the length of the outage as latency.
|
|
this._pingQueue = [];
|
|
// (#self-absent-voice-panel: Desktop "lost myself in voice" follow-up)
|
|
// Previously we _softLeave()'d the voice session immediately on every
|
|
// disconnect. Socket.io aggressively reconnects within a few hundred ms
|
|
// on transient network blips (especially Electron suspending the
|
|
// renderer momentarily), and the immediate teardown caused a cascade:
|
|
// 1. inVoice flips to false → defensive self-injection no longer
|
|
// happens → voice panel shows everyone except us.
|
|
// 2. The reconnect path then has to rebuild the mic + AudioContext +
|
|
// every RTCPeerConnection, which is heavy and prone to ICE failures
|
|
// that other peers interpret as us "going stale".
|
|
// Defer the teardown by 2 s; if we reconnect first (the common case),
|
|
// skip the soft-leave entirely. The reconnect handler will issue
|
|
// `voice-rejoin` which rebinds our voice slot to the new socketId.
|
|
//
|
|
// If WebRTC peers are still connected, NEVER soft-leave; that destroys
|
|
// working audio/streams while the user can still talk, and the server
|
|
// grace timer + missed voice-rejoin is what makes everyone else see
|
|
// "they left" even though media is live. Keep the session and wait for
|
|
// socket reconnect to rebind.
|
|
if (this.voice && this.voice.inVoice) {
|
|
if (this._voiceDisconnectTimer) clearTimeout(this._voiceDisconnectTimer);
|
|
this._voiceDisconnectTimer = setTimeout(() => {
|
|
this._voiceDisconnectTimer = null;
|
|
if (this.socket?.connected) return; // reconnected in time
|
|
if (!(this.voice && this.voice.inVoice)) return;
|
|
const peersLive = (this.voice.liveVoicePeerCount?.() || 0) > 0;
|
|
const micLive = !!(this.voice.localStream &&
|
|
this.voice.localStream.getTracks().some(t => t.readyState === 'live'));
|
|
if (peersLive || micLive) {
|
|
console.warn('[Voice] socket still down after 2s but WebRTC media is live, keeping session (no soft-leave)');
|
|
return;
|
|
}
|
|
console.warn('[Voice] socket still down after 2s, soft-leaving voice');
|
|
this.voice._softLeave();
|
|
this._updateVoiceButtons(false);
|
|
this._updateVoiceStatus(false);
|
|
this._updateVoiceBar();
|
|
}, 2000);
|
|
}
|
|
});
|
|
|
|
this.socket.on('connect_error', (err) => {
|
|
// Don't kick during password change; socket will reconnect with fresh token
|
|
if (this._justChangedPassword) return;
|
|
// These messages come from the socket.io auth middleware and are
|
|
// 100% deterministic (JWT verify failure / user row mismatch / pwv bump).
|
|
// They are NEVER transient, so we redirect to /login on the first one
|
|
// instead of stranding the user on an empty channel list. (#5375)
|
|
if (err.message === 'Invalid token' || err.message === 'Authentication required' || err.message === 'Session expired') {
|
|
this._clearChannelCodeMap();
|
|
localStorage.removeItem('haven_token');
|
|
localStorage.removeItem('haven_user');
|
|
localStorage.removeItem('haven_sync_key');
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
this._setLed('connection-led', 'danger');
|
|
this._setLed('status-server-led', 'danger');
|
|
document.getElementById('status-server-text').textContent = t('app.status.error');
|
|
});
|
|
|
|
// Password was changed on this or another session: force re-login
|
|
this.socket.on('force-logout', (data) => {
|
|
if (data && data.reason === 'password_changed') {
|
|
// If WE just changed the password, skip the kick; we already have the fresh token
|
|
if (this._justChangedPassword) {
|
|
this._justChangedPassword = false;
|
|
return;
|
|
}
|
|
this._clearChannelCodeMap();
|
|
localStorage.removeItem('haven_token');
|
|
localStorage.removeItem('haven_user');
|
|
window.location.href = '/';
|
|
} else if (data && data.reason === 'sessions_revoked') {
|
|
// We are the session that asked for this, so we already hold the fresh
|
|
// token and stay put. Every other device gets sent back to the login page.
|
|
if (this._justRevokedSessions) {
|
|
this._justRevokedSessions = false;
|
|
return;
|
|
}
|
|
this._clearChannelCodeMap();
|
|
localStorage.removeItem('haven_token');
|
|
localStorage.removeItem('haven_user');
|
|
window.location.href = '/';
|
|
} else if (data && data.reason === 'totp_enabled') {
|
|
// If WE just enabled TOTP, skip the kick; we already have the fresh token
|
|
if (this._justEnabledTotp) {
|
|
this._justEnabledTotp = false;
|
|
return;
|
|
}
|
|
this._clearChannelCodeMap();
|
|
localStorage.removeItem('haven_token');
|
|
localStorage.removeItem('haven_user');
|
|
window.location.href = '/';
|
|
}
|
|
});
|
|
|
|
this.socket.on('sessions-list', (data) => {
|
|
this._renderSessionsList?.(data && data.sessions ? data.sessions : []);
|
|
});
|
|
},
|
|
|
|
// ── Force a full socket resync ────────────────────────
|
|
// Cycles the socket and lets the existing 'connect' handler do the full
|
|
// re-fetch (enter-channel, get-messages, get-channel-members,
|
|
// request-voice-users). Used by the wake-from-sleep detector and the
|
|
// window-focus zombie probe. Debounced via _lastForcedResync so multiple
|
|
// triggers within a few seconds collapse to one cycle.
|
|
_forceFullResync(reason) {
|
|
const now = Date.now();
|
|
if (now - (this._lastForcedResync || 0) < 3000) return;
|
|
this._lastForcedResync = now;
|
|
const inVoiceLive = !!(this.voice && this.voice.inVoice &&
|
|
((this.voice.liveVoicePeerCount?.() || 0) > 0 || this.voice.localStream));
|
|
console.log(`[force-resync] reason=${reason}, socket.connected=${!!this.socket?.connected}, inVoiceLive=${inVoiceLive}`);
|
|
if (!this.socket) return;
|
|
|
|
// Hard-cycling the socket while WebRTC is healthy is what produces the
|
|
// "left voice on the roster / lost the stream / can still talk" desync
|
|
// on window maximize. Prefer a light resync whenever media is live.
|
|
if (inVoiceLive && this.socket.connected) {
|
|
this._lightVoiceResync(reason);
|
|
return;
|
|
}
|
|
|
|
try { this.socket.disconnect(); } catch (err) { console.warn('[Resync] disconnect failed', err); }
|
|
try { this.socket.connect(); } catch (err) { console.warn('[Resync] reconnect failed', err); }
|
|
// Defensive: if for some reason 'connect' doesn't fire within 6 s,
|
|
// emit the resync requests anyway against the current socket so the
|
|
// user at least gets channel data refreshed. (The connect handler is
|
|
// the authoritative path; this is purely a belt-and-braces.)
|
|
setTimeout(() => {
|
|
if (this.socket?.connected && this.currentChannel) {
|
|
// Only do this if connect handler didn't already run very recently.
|
|
const sinceConnect = Date.now() - (this._lastConnectTime || 0);
|
|
if (sinceConnect > 5000) {
|
|
try { this.socket.emit('enter-channel', { code: this.currentChannel }); } catch (err) { console.warn('[Resync] enter-channel failed', err); }
|
|
try { this.socket.emit('get-messages', { code: this.currentChannel }); } catch (err) { console.warn('[Resync] get-messages failed', err); }
|
|
try { this.socket.emit('get-channel-members', { code: this.currentChannel }); } catch (err) { console.warn('[Resync] get-channel-members failed', err); }
|
|
try { this.socket.emit('request-online-users', { code: this.currentChannel }); } catch (err) { console.warn('[Resync] request-online-users failed', err); }
|
|
try { this.socket.emit('request-voice-users', { code: this.currentChannel }); } catch (err) { console.warn('[Resync] request-voice-users failed', err); }
|
|
if (this.voice?.inVoice && this.voice.currentChannel) {
|
|
try { this.socket.emit('voice-rejoin', { code: this.voice.currentChannel, ...this.voice.getRelayClientInfo() }); } catch (err) { console.warn('[Resync] voice-rejoin failed', err); }
|
|
}
|
|
}
|
|
}
|
|
}, 6000);
|
|
},
|
|
|
|
// Refresh channel/voice state without tearing down the socket (and therefore
|
|
// without risking a server-side voice grace-eviction while WebRTC is fine).
|
|
_lightVoiceResync(reason) {
|
|
console.log(`[light-resync] reason=${reason}`);
|
|
if (!this.socket?.connected) {
|
|
try { this.socket?.connect(); } catch (err) { console.warn('[Resync] reconnect failed', err); }
|
|
return;
|
|
}
|
|
try {
|
|
if (this.currentChannel) {
|
|
this.socket.emit('enter-channel', { code: this.currentChannel });
|
|
this.socket.emit('request-online-users', { code: this.currentChannel });
|
|
this.socket.emit('request-voice-users', {
|
|
code: this.currentChannel,
|
|
iAmInVoice: !!(this.voice && this.voice.inVoice && this.voice.currentChannel === this.currentChannel)
|
|
});
|
|
}
|
|
if (this.voice?.inVoice && this.voice.currentChannel) {
|
|
// voice-rejoin is now a no-op on the server when already bound on this
|
|
// socket (skipRenegotiate). Still safe: used only to refresh roster.
|
|
this.socket.emit('voice-rejoin', { code: this.voice.currentChannel, ...this.voice.getRelayClientInfo() });
|
|
// UI may have been flipped to "Join Voice" by a partial desync, so restore.
|
|
try { this._reconcileVoiceUi?.(); } catch (err) { console.warn('[Resync] _reconcileVoiceUi failed', err); }
|
|
try { this.voice.reassertScreenStreams?.(); } catch (err) { console.warn('[Resync] reassertScreenStreams failed', err); }
|
|
}
|
|
} catch (e) {
|
|
console.warn('[light-resync] failed:', e);
|
|
}
|
|
},
|
|
|
|
};
|