diff --git a/frontend/src/utils/AuthService.ts b/frontend/src/utils/AuthService.ts index 4d627edcc5..e3ae82f235 100644 --- a/frontend/src/utils/AuthService.ts +++ b/frontend/src/utils/AuthService.ts @@ -1,6 +1,7 @@ import { APIError } from "./api"; import { urlForName } from "./router"; import appState, { AppStateService } from "./state"; +import TabSyncService from "./TabSyncService"; import type { APIUser } from "@/index"; import type { Auth } from "@/types/auth"; @@ -64,7 +65,10 @@ export default class AuthService { static logOutEvent: keyof AuthEventMap = "btrix-log-out"; static needLoginEvent: keyof AuthEventMap = "btrix-need-login"; - static broadcastChannel = new BroadcastChannel(AuthService.storageKey); + static tabSync = new TabSyncService(AuthService.storageKey); + static get broadcastChannel() { + return AuthService.tabSync.channel; + } static storage = { getItem() { return window.sessionStorage.getItem(AuthService.storageKey); @@ -201,11 +205,15 @@ export default class AuthService { "message", ({ data }: MessageEvent) => { if (data.name === "requesting_auth") { - // A new tab/window opened and is requesting shared auth - AuthService.broadcastChannel.postMessage({ - name: "responding_auth", - auth: AuthService.getCurrentTabAuth(), - } as AuthResponseEventDetail); + const auth = AuthService.getCurrentTabAuth(); + + if (auth) { + // A new tab/window opened and is requesting shared auth + AuthService.broadcastChannel.postMessage({ + name: "responding_auth", + auth: AuthService.getCurrentTabAuth(), + } as AuthResponseEventDetail); + } } }, ); @@ -241,12 +249,17 @@ export default class AuthService { }; AuthService.broadcastChannel.addEventListener("message", cb); }); + // Ensure that `getSharedSessionAuth` is resolved within a reasonable // timeframe, even if another window/tab doesn't respond: const timeoutPromise = new Promise((resolve) => { + if (!this.tabSync.tabCount || this.tabSync.tabCount === 1) { + resolve(null); + return; + } window.setTimeout(() => { resolve(null); - }, 10); + }, 500); }); return Promise.race([broadcastPromise, timeoutPromise]).then( diff --git a/frontend/src/utils/TabSyncService.ts b/frontend/src/utils/TabSyncService.ts new file mode 100644 index 0000000000..cda9db8c9a --- /dev/null +++ b/frontend/src/utils/TabSyncService.ts @@ -0,0 +1,64 @@ +import { nanoid } from "nanoid"; + +type TabSyncConfig = { tabIds: string[] }; + +/** + * Service for syncing data across tabs using `BroadcastChannel` + */ +export default class TabSyncService { + static storageKey = "btrix.tabSync"; + + public tabId = nanoid(); + public channel: BroadcastChannel; + public get tabCount() { + return this.getStoredSyncConfig()?.tabIds.length; + } + + constructor(channelName: string) { + // Open channel + this.channel = new BroadcastChannel(channelName); + + // Update number of open tabs + const syncConfig = this.getStoredSyncConfig() || { tabIds: [] }; + + syncConfig.tabIds.push(this.tabId); + + window.localStorage.setItem( + TabSyncService.storageKey, + JSON.stringify({ + ...syncConfig, + // Somewhat arbitrary, but only store latest 20 tabs to keep list managable + tabIds: syncConfig.tabIds.slice(-20), + }), + ); + + // Remove tab ID on page unload + window.addEventListener("unload", () => { + const syncConfig = this.getStoredSyncConfig(); + + if (syncConfig) { + const tabIds = syncConfig.tabIds.filter((id) => id === this.tabId); + + window.localStorage.setItem( + TabSyncService.storageKey, + JSON.stringify({ + ...syncConfig, + tabIds, + }), + ); + } + }); + } + + private getStoredSyncConfig(): TabSyncConfig | null { + const storedSyncConfig = window.localStorage.getItem( + TabSyncService.storageKey, + ); + + if (storedSyncConfig) { + return JSON.parse(storedSyncConfig) as TabSyncConfig; + } + + return null; + } +}