// @flow
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import Editor, { type Props as EditorProps } from "components/Editor";
import PlaceholderDocument from "components/PlaceholderDocument";
import env from "env";
import useCurrentToken from "hooks/useCurrentToken";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import useUnmount from "hooks/useUnmount";
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
import { homeUrl } from "utils/routeHelpers";
type Props = {|
...EditorProps,
id: string,
|};
function MultiplayerEditor(props: Props, ref: any) {
const documentId = props.id;
const history = useHistory();
const { t } = useTranslation();
const currentUser = useCurrentUser();
const { presence, ui } = useStores();
const token = useCurrentToken();
const [localProvider, setLocalProvider] = React.useState();
const [remoteProvider, setRemoteProvider] = React.useState();
const [isLocalSynced, setLocalSynced] = React.useState(false);
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
const [ydoc] = React.useState(() => new Y.Doc());
const { showToast } = useToasts();
// Provider initialization must be within useLayoutEffect rather than useState
// or useMemo as both of these are ran twice in React StrictMode resulting in
// an orphaned websocket connection.
// see: https://github.com/facebook/react/issues/20090#issuecomment-715926549
React.useLayoutEffect(() => {
const debug = env.ENVIRONMENT === "development";
const name = `document.${documentId}`;
const localProvider = new IndexeddbPersistence(name, ydoc);
const provider = new HocuspocusProvider({
url: `${env.COLLABORATION_URL}/collaboration`,
debug,
name,
document: ydoc,
token,
maxReconnectTimeout: 10000,
});
provider.on("authenticationFailed", () => {
showToast(
t(
"Sorry, it looks like you don’t have permission to access the document"
)
);
history.replace(homeUrl());
});
provider.on("awarenessChange", ({ states }) => {
states.forEach(({ user }) => {
if (user) {
// could know if the user is editing here using `state.cursor` but it
// feels distracting in the UI, once multiplayer is on for everyone we
// can stop diffentiating
presence.touch(documentId, user.id, false);
}
});
});
localProvider.on("synced", () => setLocalSynced(true));
provider.on("synced", () => setRemoteSynced(true));
if (debug) {
provider.on("status", (ev) => console.log("status", ev.status));
provider.on("message", (ev) => console.log("incoming", ev.message));
provider.on("outgoingMessage", (ev) =>
console.log("outgoing", ev.message)
);
localProvider.on("synced", (ev) => console.log("local synced"));
}
provider.on("status", (ev) => ui.setMultiplayerStatus(ev.status));
setRemoteProvider(provider);
setLocalProvider(localProvider);
}, [history, showToast, t, documentId, ui, presence, token, ydoc]);
const user = React.useMemo(() => {
return {
id: currentUser.id,
name: currentUser.name,
color: currentUser.color,
};
}, [currentUser.id, currentUser.color, currentUser.name]);
const extensions = React.useMemo(() => {
if (!remoteProvider) {
return [];
}
return [
new MultiplayerExtension({
user,
provider: remoteProvider,
document: ydoc,
}),
];
}, [remoteProvider, user, ydoc]);
useUnmount(() => {
remoteProvider?.destroy();
localProvider?.destroy();
ui.setMultiplayerStatus(undefined);
});
if (!extensions.length) {
return null;
}
if (isLocalSynced && !isRemoteSynced && !ydoc.get("default")._start) {
return ;
}
return (
);
}
export default React.forwardRef(
MultiplayerEditor
);