Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: essay
> ./src/DocExplorer/components/SyncIndicator.tsx
Lenses
(coming soon!)
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
// TODO move these utils
import { arraysAreEqual, getRelativeTimeString } from "../utils";
import { next as A } from "@automerge/automerge";
import { AutomergeUrl, DocHandle, StorageId } from "@automerge/automerge-repo";
import { useHandle, useRepo } from "@automerge/automerge-repo-react-hooks";
import { useMachine } from "@xstate/react";
import { WifiIcon, WifiOffIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createMachine, raise, stateIn } from "xstate";
export const SyncIndicator = ({ docUrl }: { docUrl: AutomergeUrl }) => {
const handle = useHandle(docUrl);
if (!handle) {
return null;
}
return <SyncIndicatorInner key={handle.url} handle={handle} />;
};
// NOTE: this sync indicator component does *not* support changing the handle between renders.
// If you want to change the handle, you should re-mount the component.
const SyncIndicatorInner = ({ handle }: { handle: DocHandle<unknown> }) => {
const {
lastSyncUpdate,
isInternetConnected,
syncState,
syncServerConnectionError,
syncServerResponseError,
syncServerHeads,
ownHeads,
} = useSyncIndicatorState(handle);
const isSynced = syncState === SyncState.InSync;
const prevHandle = useRef(undefined);
useEffect(() => {
if (prevHandle.current && prevHandle.current.url !== handle.url) {
console.warn(
"Warning: do not change the handle between renders of SyncIndicator",
{
previous: prevHandle.current.url,
current: handle.url,
}
);
}
prevHandle.current = handle;
}, [handle]);
const headsView = (
<div className="mt-2 pt-2 border-t border-gray-300">
<div className="whitespace-nowrap flex">
<dt className="font-bold inline mr-1">Server heads:</dt>
<dd className="inline text-ellipsis flex-shrink overflow-hidden min-w-0">
{JSON.stringify(
(syncServerHeads ?? []).map((part) => part.slice(0, 4))
)}
</dd>
</div>
<div className="whitespace-nowrap flex">
<dt className="font-bold inline mr-1">Local heads:</dt>
<dd className="inline text-ellipsis flex-shrink overflow-hidden min-w-0">
{JSON.stringify((ownHeads ?? []).map((part) => part.slice(0, 4)))}
</dd>
</div>
</div>
);
if (isInternetConnected) {
if (!syncServerConnectionError && !syncServerResponseError) {
return (
<Popover>
<PopoverTrigger className=" p-1 rounded-md text-gray-500 hover:text-gray-900 align-top">
<WifiIcon size={"20px"} />
</PopoverTrigger>
<PopoverContent className="flex flex-col gap-1.5 pb-2">
<dl className="text-sm text-gray-600">
<div>
<dt className="font-bold inline mr-1">Connection:</dt>
<dd className="inline">Connected to server</dd>
</div>
<div>
<dt className="font-bold inline mr-1">Last synced:</dt>
<dd className="inline">
{lastSyncUpdate ? getRelativeTimeString(lastSyncUpdate) : "-"}
</dd>
</div>
<div>
<dt className="font-bold inline mr-1">Sync status:</dt>
<dd className="inline">
{isSynced ? "Up to date" : "Syncing..."}
</dd>
</div>
{headsView}
</dl>
</PopoverContent>
</Popover>
);
} else {
return (
<Popover>
<PopoverTrigger className="bg-red-50 border border-red-100 hover:bg-red-100 p-2 rounded-md">
<div className="text-red-500 flex items-center text-sm">
<WifiIcon
size={"20px"}
className={`inline-block ${isSynced ? "mr-[7px]" : ""}`}
/>
{!isSynced && <div className="inline text-xs">*</div>}
</div>
</PopoverTrigger>
<PopoverContent className="flex flex-col gap-1.5 pb-2">
<div className="mb-2 text-sm">
<p>
There was an unexpected error connecting to the sync server.
Don't worry, your changes are saved locally.
</p>
<p className="mt-2">
Please try reloading and see if that fixes the issue. If not,
drop a note in the lab Discord with a screenshot.
</p>
</div>
<dl className="text-sm text-gray-600">
<div>
<dt className="font-bold inline mr-1">Connection:</dt>
<dd className="inline text-red-500">
{syncServerConnectionError
? "Server not connected"
: "Server not responding"}
</dd>
</div>
<div>
<dt className="font-bold inline mr-1">Last synced:</dt>
<dd className="inline">
{lastSyncUpdate ? getRelativeTimeString(lastSyncUpdate) : "-"}
</dd>
</div>
<div>
<dt className="font-bold inline mr-1">Sync status:</dt>
<dd className="inline">
{syncState === SyncState.Unknown ? (
"-"
) : syncState === SyncState.InSync ? (
"No unsynced changes"
) : (
<span className="text-red-500">Unsynced changes (*)</span>
)}
</dd>
{headsView}
</div>
</dl>
</PopoverContent>
</Popover>
);
}
} else {
return (
<Popover>
<PopoverTrigger className="hover:bg-gray-100 p-2 rounded-md">
<div className="text-gray-500">
<WifiOffIcon
size={"20px"}
className={`inline-block ${isSynced ? "mr-[7px]" : ""}`}
/>
{!isSynced && (
<div className="inline text-xs font-bold text-red-600">*</div>
)}
</div>
</PopoverTrigger>
<PopoverContent>
<dl className="text-sm text-gray-600">
<div>
<dt className="font-bold inline mr-1">Connection:</dt>
<dd className="inline">Offline</dd>
</div>
<div>
<dt className="font-bold inline mr-1">Last synced:</dt>
<dd className="inline">
{lastSyncUpdate ? getRelativeTimeString(lastSyncUpdate) : "-"}
</dd>
</div>
<div>
<dt className="font-bold inline mr-1">Sync status:</dt>
<dd className="inline">
{syncState === SyncState.Unknown ? (
"-"
) : isSynced ? (
"No unsynced changes"
) : (
<span className="text-red-500">
You have unsynced changes. They are saved locally and will
sync next time you have internet and you open the app.
</span>
)}
</dd>
</div>
{headsView}
</dl>
</PopoverContent>
</Popover>
);
}
};
const SYNC_SERVER_STORAGE_ID = (import.meta.env?.VITE_SYNC_SERVER_STORAGE_ID ??
"3760df37-a4c6-4f66-9ecd-732039a9385d") as StorageId;
enum SyncState {
InSync,
OutOfSync,
Unknown,
}
interface SyncIndicatorState {
syncServerHeads: A.Heads;
ownHeads: A.Heads;
lastSyncUpdate?: number;
isInternetConnected: boolean;
syncState: SyncState;
syncServerConnectionError: boolean;
syncServerResponseError: boolean;
}
function useSyncIndicatorState(handle: DocHandle<unknown>): SyncIndicatorState {
const repo = useRepo();
const [lastSyncUpdate, setLastSyncUpdate] = useState<number | undefined>(); // todo: should load that from persisted sync state
const [syncServerHeads, setSyncServerHeads] = useState<A.Heads | undefined>();
const [ownHeads, setOwnHeads] = useState<A.Heads | undefined>();
useEffect(() => {
repo.subscribeToRemotes([SYNC_SERVER_STORAGE_ID]);
}, [repo]);
const [machineConfig] = useState(() =>
getSyncIndicatorMachine({
connectionInitTimeout: 2000,
maxSyncMessageDelay: 1000,
isInternetConnected: navigator.onLine,
isSyncServerConnected: true,
})
);
const [machine, send] = useMachine(machineConfig);
// online / offline listener
useEffect(() => {
const onOnline = () => {
send({ type: "INTERNET_CONNECTED" });
};
const onOffline = () => {
send({ type: "INTERNET_DISCONNECTED" });
};
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, [send]);
// sync server connect / disconnect handling
// todo: need reachability information for that
// heads change listener
useEffect(() => {
if (machine.matches("sync.unknown")) {
const syncServerHeads = handle.getRemoteHeads(SYNC_SERVER_STORAGE_ID);
setSyncServerHeads(syncServerHeads ?? []); // initialize to empty heads if we have no state
handle.doc().then((doc) => {
setOwnHeads(A.getHeads(doc));
});
}
const onChange = () => {
const doc = handle.docSync();
if (doc) {
setOwnHeads(A.getHeads(doc));
}
};
const onRemoteHeads = ({ storageId, heads }) => {
if (storageId === SYNC_SERVER_STORAGE_ID) {
send({ type: "RECEIVED_SYNC_MESSAGE" });
setSyncServerHeads(heads);
setLastSyncUpdate(Date.now());
}
};
handle.on("change", onChange);
handle.on("remote-heads", onRemoteHeads);
return () => {
handle.off("change", onChange);
handle.off("remote-heads", onRemoteHeads);
};
}, [handle]);
useEffect(() => {
if (!ownHeads || !syncServerHeads) {
return;
}
if (arraysAreEqual(ownHeads, syncServerHeads)) {
send({ type: "IS_IN_SYNC" });
} else {
send({ type: "IS_OUT_OF_SYNC" });
}
}, [ownHeads, syncServerHeads]);
return {
ownHeads,
syncServerHeads,
lastSyncUpdate,
isInternetConnected: machine.matches("internet.connected"),
syncState: machine.matches("sync.unknown")
? SyncState.Unknown
: machine.matches("sync.inSync")
? SyncState.InSync
: SyncState.OutOfSync,
// todo: add reachability check, currently this value will be always true
syncServerConnectionError: machine.matches("syncServer.disconnected.error"),
syncServerResponseError: machine.matches("sync.outOfSync.error"),
};
}
interface SyncIndicatorMachineConfig {
// the duration we wait for the sync server to respond in the unsynced state before we show an error
// the timer starts once both internet.connected and sync.isOutOfSync become true
connectionInitTimeout: number;
// the duration we wait for the sync server to respond in the unsynced state before we show an error
// the timer starts once both internet.connected and sync.isOutOfSync become true
maxSyncMessageDelay: number;
// initial internet connection state
isInternetConnected?: boolean;
// initial sync server connection state
isSyncServerConnected?: boolean;
// initial is sync state
isInSync?: boolean;
}
export function getSyncIndicatorMachine({
connectionInitTimeout,
maxSyncMessageDelay,
isInternetConnected = false,
isSyncServerConnected = false,
}: SyncIndicatorMachineConfig) {
return createMachine(
{
id: "syncIndicator",
type: "parallel",
states: {
internet: {
initial: isInternetConnected ? "connected" : "disconnected",
states: {
connected: {
after: {
[connectionInitTimeout]: {
actions: "connectionInitTimeout",
},
},
on: {
INTERNET_DISCONNECTED: "disconnected",
},
},
disconnected: {
on: {
INTERNET_CONNECTED: "connected",
},
},
},
},
sync: {
initial: "unknown",
states: {
unknown: {
on: {
IS_OUT_OF_SYNC: "outOfSync",
IS_IN_SYNC: "inSync",
},
},
inSync: {
on: {
IS_OUT_OF_SYNC: "outOfSync",
},
},
outOfSync: {
initial: "ok",
after: {
// every time we re-enter the out of sync state the timeout gets reset
[maxSyncMessageDelay]: {
target: ".error",
guard: stateIn("internet.connected"),
},
},
on: {
IS_IN_SYNC: "inSync",
RECEIVED_SYNC_MESSAGE: "outOfSync",
CONNECTION_INIT_TIMEOUT: "outOfSync",
},
states: {
ok: {},
error: {},
},
},
},
},
syncServer: {
initial: isSyncServerConnected ? "connected" : "disconnected",
states: {
connected: {
on: {
SYNC_SERVER_DISCONNECTED: "disconnected.error",
},
},
disconnected: {
initial: "ok",
on: {
SYNC_SERVER_CONNECTED: "connected",
CONNECTION_INIT_TIMEOUT: ".error",
},
states: {
ok: {},
error: {},
},
},
},
},
},
},
{
actions: {
connectionInitTimeout: raise({ type: "CONNECTION_INIT_TIMEOUT" }),
},
}
);
}