import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk'; import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; import { clearPusherIds, loadPusherIds, setPushEnabled, unregisterPusher } from '../app/utils/push'; import { isNativePlatform } from '../app/utils/capacitor'; import { clearSessionBridge } from '../app/utils/sessionBridge'; import { polling } from '../app/plugins/polling'; type Session = { baseUrl: string; accessToken: string; userId: string; deviceId: string; }; export const initClient = async (session: Session): Promise => { const indexedDBStore = new IndexedDBStore({ indexedDB: global.indexedDB, localStorage: global.localStorage, dbName: 'web-sync-store', }); const legacyCryptoStore = new IndexedDBCryptoStore(global.indexedDB, 'crypto-store'); const mx = createClient({ baseUrl: session.baseUrl, accessToken: session.accessToken, userId: session.userId, store: indexedDBStore, cryptoStore: legacyCryptoStore, deviceId: session.deviceId, timelineSupport: true, // cryptoCallbacks ships from a .js module with a duck-typed shape; matrix-js-sdk's exported type is too narrow. // eslint-disable-next-line @typescript-eslint/no-explicit-any cryptoCallbacks: cryptoCallbacks as any, verificationMethods: ['m.sas.v1'], }); await indexedDBStore.startup(); await mx.initRustCrypto(); mx.setMaxListeners(50); return mx; }; export const startClient = async (mx: MatrixClient) => { // threadSupport partitions m.thread relations into Thread objects so // /channels thread drawer can read room.getThread(rootId), receive // ThreadEvent.New / Update from the room emitter, and let // sendReadReceipt auto-route thread_id. SDK 38.2+ default is false: // without this flag drawer reads silently NO-OP. Flag is global — // also affects DM/Bots receipt shape (SDK now adds thread_id: 'main' // to main-timeline receipts and per-thread unread shape changes // arrive in `unread_thread_notifications`). M4 will consume the // shape; M2 just enables it so the drawer surface works. await mx.startClient({ lazyLoadMembers: true, threadSupport: true, }); }; export const clearCacheAndReload = async (mx: MatrixClient) => { mx.stopClient(); clearNavToActivePathStore(mx.getSafeUserId()); await mx.store.deleteAllData(); window.location.reload(); }; export const logoutClient = async (mx: MatrixClient) => { // 1. Deactivate pusher on the homeserver while we still have a valid token. // Run before pushSessionToSW() so that if a push arrives mid-logout, the // SW still has a working session to resolve event details with. const pusherIds = loadPusherIds(); if (pusherIds) { try { await unregisterPusher(mx, pusherIds.pushkey, pusherIds.appId); } catch { // ignore — logout must proceed } } pushSessionToSW(); // 2. Unsubscribe locally so the browser/FCM stops delivering to this device. // Critical: if step 1 failed (bad network, server 5xx), this is the only // thing that actually stops pushes from arriving at this installation. try { if (isNativePlatform()) { const { PushNotifications } = await import('@capacitor/push-notifications'); await PushNotifications.unregister(); } else if ('serviceWorker' in navigator) { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); if (sub) await sub.unsubscribe(); } } catch { // ignore } clearPusherIds(); setPushEnabled(false); // Tear down the WorkManager polling fallback synchronously here — the // React lifecycle cleanup that does the same thing runs async and we // don't get a chance to await it before window.location.replace below. // Without this explicit await, the Worker can fire one more time with // the old access_token and surface notifications belonging to the // logged-out account. await polling.cancel(); await polling.clearSession(); // Wipe the native session bridge so a re-login with a different user // can't resurrect the old access_token via CallDeclineReceiver. await clearSessionBridge(); mx.stopClient(); try { await mx.logout(); } catch { // ignore if failed to logout } await mx.clearStores(); window.localStorage.clear(); // Navigate to root instead of reload(): on Android Capacitor the // WebViewLocalServer fails to SPA-fallback on URLs with `%3A` in path // segments (Matrix room/space ids), so reload of `///` // or `/channels//...` returns ERR_HTTP_RESPONSE_CODE_FAILURE. // Replacing to `/` always resolves to index.html; the Router index // loader then redirects to the login page since the session is gone. window.location.replace('/'); }; export const clearLoginData = async () => { const dbs = await window.indexedDB.databases(); dbs.forEach((idbInfo) => { const { name } = idbInfo; if (name) { window.indexedDB.deleteDatabase(name); } }); window.localStorage.clear(); window.location.reload(); }; // Boot-error logout: full LOCAL cleanup with no network calls — useful when the // network is the reason we're stuck (sync error, init/start failure). Wipes // the native session bridge so CallDeclineReceiver can't resurrect a dead // token, clears push state so a re-login doesn't double-register, and nukes // IndexedDB + localStorage. Server-side logout is skipped — the homeserver // will time out the session naturally. export const clearLocalSessionAndReload = async () => { await clearSessionBridge(); // Same reasoning as the normal logoutClient: kill the WorkManager // polling fallback before the reload so it can't fire one more time // with the old access_token and surface notifications from the // logged-out account. await polling.cancel(); await polling.clearSession(); clearPusherIds(); setPushEnabled(false); const dbs = await window.indexedDB.databases(); dbs.forEach((idbInfo) => { const { name } = idbInfo; if (name) { window.indexedDB.deleteDatabase(name); } }); window.localStorage.clear(); window.location.reload(); };