diff --git a/pages/_app.tsx b/pages/_app.tsx index e810510fb..1fc891e48 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -11,6 +11,7 @@ import '~/common/styles/CodePrism.css'; import '~/common/styles/GithubMarkdown.css'; import { ProviderBackend } from '~/common/state/ProviderBackend'; +import { ProviderSingleTab } from '~/common/state/ProviderSingleTab'; import { ProviderSnacks } from '~/common/state/ProviderSnacks'; import { ProviderTRPCQueryClient } from '~/common/state/ProviderTRPCQueryClient'; import { ProviderTheming } from '~/common/state/ProviderTheming'; @@ -25,13 +26,15 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) => - - - - - - - + + + + + + + + + diff --git a/src/common/app.routes.ts b/src/common/app.routes.ts index e3281e602..a70994303 100644 --- a/src/common/app.routes.ts +++ b/src/common/app.routes.ts @@ -7,6 +7,7 @@ import Router from 'next/router'; import type { DConversationId } from '~/common/state/store-chats'; +import { isBrowser } from './util/pwaUtils'; export const ROUTE_INDEX = '/'; @@ -15,6 +16,7 @@ export const ROUTE_APP_LINK_CHAT = '/link/chat/:linkId'; export const ROUTE_APP_NEWS = '/news'; const ROUTE_CALLBACK_OPENROUTER = '/link/callback_openrouter'; + // Get Paths export const getCallbackUrl = (source: 'openrouter') => { @@ -55,6 +57,8 @@ export const navigateToNews = navigateFn(ROUTE_APP_NEWS); export const navigateBack = Router.back; +export const reloadPage = () => isBrowser && window.location.reload(); + function navigateFn(path: string) { return (replace?: boolean): Promise => Router[replace ? 'replace' : 'push'](path); } diff --git a/src/common/components/useSingleTabEnforcer.ts b/src/common/components/useSingleTabEnforcer.ts new file mode 100644 index 000000000..01ee765fe --- /dev/null +++ b/src/common/components/useSingleTabEnforcer.ts @@ -0,0 +1,95 @@ +import * as React from 'react'; + +/** + * The AloneDetector class checks if the current client is the only one present for a given app. It uses + * BroadcastChannel to talk to other clients. If no other clients reply within a short time, it assumes it's + * the only one and tells the caller. + */ +class AloneDetector { + private readonly clientId: string; + private readonly broadcastChannel: BroadcastChannel; + + private aloneCallback: ((isAlone: boolean) => void) | null; + private aloneTimerId: number | undefined; + + constructor(channelName: string, onAlone: (isAlone: boolean) => void) { + + this.clientId = Math.random().toString(36).substring(2, 10); + this.aloneCallback = onAlone; + + this.broadcastChannel = new BroadcastChannel(channelName); + this.broadcastChannel.onmessage = this.handleIncomingMessage; + + } + + public onUnmount(): void { + // close channel + this.broadcastChannel.onmessage = null; + this.broadcastChannel.close(); + + // clear timeout + if (this.aloneTimerId) + clearTimeout(this.aloneTimerId); + + this.aloneTimerId = undefined; + this.aloneCallback = null; + } + + public checkIfAlone(): void { + + // triggers other clients + this.broadcastChannel.postMessage({ type: 'CHECK', sender: this.clientId }); + + // if no response within 500ms, assume this client is alone + this.aloneTimerId = window.setTimeout(() => { + this.aloneTimerId = undefined; + this.aloneCallback?.(true); + }, 500); + + } + + private handleIncomingMessage = (event: MessageEvent): void => { + + // ignore self messages + if (event.data.sender === this.clientId) return; + + switch (event.data.type) { + + case 'CHECK': + this.broadcastChannel.postMessage({ type: 'ALIVE', sender: this.clientId }); + break; + + case 'ALIVE': + // received an ALIVE message, tell the client they're not alone + if (this.aloneTimerId) { + clearTimeout(this.aloneTimerId); + this.aloneTimerId = undefined; + } + this.aloneCallback?.(false); + this.aloneCallback = null; + break; + + } + }; +} + + +/** + * React hook that checks whether the current tab is the only one open for a specific channel. + * + * @param {string} channelName - The name of the BroadcastChannel to communicate on. + * @returns {boolean | null} - True if the current tab is alone, false if not, or null before the check completes. + */ +export function useSingleTabEnforcer(channelName: string): boolean | null { + const [isAlone, setIsAlone] = React.useState(null); + + React.useEffect(() => { + const tabManager = new AloneDetector(channelName, setIsAlone); + tabManager.checkIfAlone(); + return () => { + tabManager.onUnmount(); + }; + }, [channelName]); + + return isAlone; +} \ No newline at end of file diff --git a/src/common/state/ProviderSingleTab.tsx b/src/common/state/ProviderSingleTab.tsx new file mode 100644 index 000000000..bded29888 --- /dev/null +++ b/src/common/state/ProviderSingleTab.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; + +import { Button, Sheet, Typography } from '@mui/joy'; + +import { Brand } from '../app.config'; +import { reloadPage } from '../app.routes'; +import { useSingleTabEnforcer } from '../components/useSingleTabEnforcer'; + + +export const ProviderSingleTab = (props: { children: React.ReactNode }) => { + + // state + const isSingleTab = useSingleTabEnforcer('big-agi-tabs'); + + // pass-through until we know for sure that other tabs are open + if (isSingleTab === null || isSingleTab) + return props.children; + + + return ( + + + + It looks like {Brand.Title.Base} is already running in another tab or window. + To continue here, please close the other instance first. + + + + + + ); +}; \ No newline at end of file