Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 31 additions & 84 deletions src/common/components/useSingleTabEnforcer.ts
Original file line number Diff line number Diff line change
@@ -1,95 +1,42 @@
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.
* A tiny LocalStorage-based single-tab enforcer.
* Returns `[isActive, activate]`, where `isActive` is true if this tab
* currently "owns" the app, and `activate()` lets this tab claim ownership.
*/
class AloneDetector {
private readonly clientId: string;
private readonly bChannel: 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.bChannel = new BroadcastChannel(channelName);
this.bChannel.onmessage = this.handleIncomingMessage;

}

public onUnmount(): void {
// close channel
this.bChannel.onmessage = null;
this.bChannel.close();

// clear timeout
if (this.aloneTimerId)
clearTimeout(this.aloneTimerId);

this.aloneTimerId = undefined;
this.aloneCallback = null;
}

public checkIfAlone(): void {

// triggers other clients
this.bChannel.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.bChannel.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;

}
};
}
export function useSingleTabEnforcer(
channelName: string
): [isActive: boolean, activate: () => void] {
const clientId = React.useRef(Math.random().toString(36).slice(2, 10)).current;
const storageKey = `${channelName}:activeId`;
const [isActive, setIsActive] = React.useState(true);

React.useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === storageKey) {
setIsActive(e.newValue === clientId);
}
};
window.addEventListener('storage', onStorage);

/**
* 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);
// Claim this tab as active on mount
localStorage.setItem(storageKey, clientId);
setIsActive(true);

React.useEffect(() => {
const tabManager = new AloneDetector(channelName, setIsAlone);
tabManager.checkIfAlone();
return () => {
tabManager.onUnmount();
window.removeEventListener('storage', onStorage);
// On unmount, if we're still the owner, clear the lock
if (localStorage.getItem(storageKey) === clientId) {
localStorage.removeItem(storageKey);
}
};
}, [channelName]);
}, [storageKey, clientId]);

const activate = React.useCallback(() => {
localStorage.setItem(storageKey, clientId);
setIsActive(true);
}, [storageKey, clientId]);

return isAlone;
return [isActive, activate];
}
19 changes: 9 additions & 10 deletions src/common/providers/ProviderSingleTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@ import * as React from 'react';

import { Button, Sheet, Typography } from '@mui/joy';

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;
// state: [isActive, activate]
const [isActive, activate] = useSingleTabEnforcer('big-agi-tabs');

// only render the app when this tab owns it
if (isActive) {
return <>{props.children}</>;
}

return (
<Sheet
Expand All @@ -29,11 +28,11 @@ export const ProviderSingleTab = (props: { children: React.ReactNode }) => {

<Typography>
It looks like this app is already running in another browser Tab or Window.<br />
To continue here, please close the other instance first.
Click "Use here" to switch to this window.
</Typography>

<Button onClick={reloadPage}>
Reload
<Button onClick={activate}>
Use here
</Button>

</Sheet>
Expand Down