Skip to content

Commit

Permalink
Enforce a Single instance (Tab) of the app. Closes #268
Browse files Browse the repository at this point in the history
  • Loading branch information
enricoros committed Dec 13, 2023
1 parent 084d77c commit ba1c0ba
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 7 deletions.
17 changes: 10 additions & 7 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,13 +26,15 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
</Head>

<ProviderTheming emotionCache={emotionCache}>
<ProviderTRPCQueryClient>
<ProviderSnacks>
<ProviderBackend>
<Component {...pageProps} />
</ProviderBackend>
</ProviderSnacks>
</ProviderTRPCQueryClient>
<ProviderSingleTab>
<ProviderTRPCQueryClient>
<ProviderSnacks>
<ProviderBackend>
<Component {...pageProps} />
</ProviderBackend>
</ProviderSnacks>
</ProviderTRPCQueryClient>
</ProviderSingleTab>
</ProviderTheming>

<VercelAnalytics debug={false} />
Expand Down
4 changes: 4 additions & 0 deletions src/common/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '/';
Expand All @@ -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') => {
Expand Down Expand Up @@ -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<boolean> => Router[replace ? 'replace' : 'push'](path);
}
Expand Down
95 changes: 95 additions & 0 deletions src/common/components/useSingleTabEnforcer.ts
Original file line number Diff line number Diff line change
@@ -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<boolean | null>(null);

React.useEffect(() => {
const tabManager = new AloneDetector(channelName, setIsAlone);
tabManager.checkIfAlone();
return () => {
tabManager.onUnmount();
};
}, [channelName]);

return isAlone;
}
42 changes: 42 additions & 0 deletions src/common/state/ProviderSingleTab.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Sheet
variant='solid'
invertedColors
sx={{
flexGrow: 1,
display: 'flex', flexDirection: { xs: 'column', md: 'row' }, justifyContent: 'center', alignItems: 'center', gap: 2,
p: { xs: 3, md: 1 },
}}
>

<Typography>
It looks like {Brand.Title.Base} is already running in another tab or window.
To continue here, please close the other instance first.
</Typography>

<Button onClick={reloadPage}>
Reload
</Button>

</Sheet>
);
};

1 comment on commit ba1c0ba

@vercel
Copy link

@vercel vercel bot commented on ba1c0ba Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

big-agi – ./

big-agi-enricoros.vercel.app
big-agi-git-main-stable-enricoros.vercel.app
get.big-agi.com

Please sign in to comment.