mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
fca10221b9
* chore: promote no-explicit-any from warn to error and resolve violations Upgrades the oxlint rule severity and removes all 40 existing `no-explicit-any` warnings across the codebase. Most call sites gained proper types (SharedEditor refs, JSONNode/JSONMark for ProseMirror JSON walking, DocumentsStore, dd-trace `Span` parameter inference, prosemirror Fragment public API in place of internal `(fragment as any).content`). A few load-bearing `any` uses were preserved with scoped disable comments where changing the type would cascade widely (Sequelize JSONB columns on `Event`, the `withTracing` higher-order function generic, `Extension.options` consumed by many subclasses, dd-trace's `req` patching). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
361 lines
10 KiB
TypeScript
361 lines
10 KiB
TypeScript
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
|
||
import throttle from "lodash/throttle";
|
||
import {
|
||
useState,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useEffect,
|
||
forwardRef,
|
||
useRef,
|
||
type ForwardedRef,
|
||
} from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useHistory } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
import { IndexeddbPersistence } from "y-indexeddb";
|
||
import * as Y from "yjs";
|
||
import { EditorUpdateError } from "@shared/collaboration/CloseEvents";
|
||
import EDITOR_VERSION from "@shared/editor/version";
|
||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||
import type { Props as EditorProps } from "~/components/Editor";
|
||
import Editor from "~/components/Editor";
|
||
import type { Editor as SharedEditor } from "~/editor";
|
||
import MultiplayerExtension from "~/editor/extensions/Multiplayer";
|
||
import env from "~/env";
|
||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||
import useIdle from "~/hooks/useIdle";
|
||
import useIsMounted from "~/hooks/useIsMounted";
|
||
import usePageVisibility from "~/hooks/usePageVisibility";
|
||
import useStores from "~/hooks/useStores";
|
||
import type { AwarenessChangeEvent } from "~/types";
|
||
import Logger from "~/utils/Logger";
|
||
import { homePath } from "~/utils/routeHelpers";
|
||
import { sleep } from "@shared/utils/timers";
|
||
|
||
type Props = EditorProps & {
|
||
id: string;
|
||
onSynced?: () => Promise<void>;
|
||
};
|
||
|
||
export type ConnectionStatus =
|
||
| "connecting"
|
||
| "connected"
|
||
| "disconnected"
|
||
| void;
|
||
|
||
type ConnectionStatusEvent = { status: ConnectionStatus };
|
||
|
||
type MessageEvent = {
|
||
message: string;
|
||
event: Event & {
|
||
code?: number;
|
||
};
|
||
};
|
||
|
||
function MultiplayerEditor(
|
||
{ onSynced, ...props }: Props,
|
||
ref: ForwardedRef<SharedEditor>
|
||
) {
|
||
const documentId = props.id;
|
||
const history = useHistory();
|
||
const { t } = useTranslation();
|
||
const currentUser = useCurrentUser();
|
||
const retryCount = useRef(0);
|
||
const { presence, auth, ui } = useStores();
|
||
const [editorVersionBehind, setEditorVersionBehind] = useState(false);
|
||
const [showCursorNames, setShowCursorNames] = useState(false);
|
||
const [remoteProvider, setRemoteProvider] = useState<HocuspocusProvider>();
|
||
const [hasLocalPersistence, setHasLocalPersistence] = useState(true);
|
||
const [isLocalSynced, setLocalSynced] = useState(false);
|
||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||
const [ydoc] = useState(() => new Y.Doc());
|
||
const token = auth.collaborationToken;
|
||
const isIdle = useIdle();
|
||
const isVisible = usePageVisibility();
|
||
const isMounted = useIsMounted();
|
||
|
||
// 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
|
||
useLayoutEffect(() => {
|
||
const debug = env.ENVIRONMENT === "development";
|
||
const name = `document.${documentId}`;
|
||
const localProvider =
|
||
typeof indexedDB !== "undefined"
|
||
? new IndexeddbPersistence(name, ydoc)
|
||
: undefined;
|
||
|
||
if (!localProvider) {
|
||
setHasLocalPersistence(false);
|
||
}
|
||
|
||
const provider = new HocuspocusProvider({
|
||
parameters: {
|
||
editorVersion: EDITOR_VERSION,
|
||
},
|
||
url: `${env.COLLABORATION_URL}/collaboration`,
|
||
name,
|
||
document: ydoc,
|
||
token,
|
||
});
|
||
|
||
const syncScrollPosition = throttle(() => {
|
||
provider.setAwarenessField(
|
||
"scrollY",
|
||
window.scrollY / window.innerHeight
|
||
);
|
||
}, 250);
|
||
|
||
const finishObserving = () => {
|
||
if (ui.observingUserId) {
|
||
ui.setObservingUser(undefined);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("click", finishObserving);
|
||
window.addEventListener("wheel", finishObserving);
|
||
window.addEventListener(
|
||
"scroll",
|
||
syncScrollPosition,
|
||
supportsPassiveListener ? { passive: true } : false
|
||
);
|
||
|
||
provider.on("authenticationFailed", () => {
|
||
provider.shouldConnect = false;
|
||
retryCount.current++;
|
||
|
||
void sleep(retryCount.current * 1000 - 1000).then(() =>
|
||
auth
|
||
.fetchAuth()
|
||
.then(() => {
|
||
provider.setConfiguration({ token: auth.collaborationToken });
|
||
provider.connect();
|
||
provider.shouldConnect = true;
|
||
})
|
||
.catch(() => {
|
||
history.replace(homePath());
|
||
})
|
||
);
|
||
});
|
||
|
||
provider.on("awarenessChange", (event: AwarenessChangeEvent) => {
|
||
presence.updateFromAwarenessChangeEvent(
|
||
documentId,
|
||
provider.awareness.clientID,
|
||
event
|
||
);
|
||
|
||
event.states.forEach(({ user, scrollY }) => {
|
||
if (user) {
|
||
if (scrollY !== undefined && user.id === ui.observingUserId) {
|
||
window.scrollTo({
|
||
top: scrollY * window.innerHeight,
|
||
behavior: "smooth",
|
||
});
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
const showCursorNames = () => {
|
||
setShowCursorNames(true);
|
||
setTimeout(() => {
|
||
if (isMounted()) {
|
||
setShowCursorNames(false);
|
||
}
|
||
}, 2000);
|
||
provider.off("awarenessChange", showCursorNames);
|
||
};
|
||
|
||
provider.on("awarenessChange", showCursorNames);
|
||
localProvider?.on("synced", () =>
|
||
// only set local storage to "synced" if it's loaded a non-empty doc
|
||
setLocalSynced(!!ydoc.get("default")._start)
|
||
);
|
||
provider.on("synced", () => {
|
||
presence.touch(documentId, currentUser.id, false);
|
||
setRemoteSynced(true);
|
||
retryCount.current = 0;
|
||
});
|
||
|
||
provider.on("close", (ev: MessageEvent) => {
|
||
if ("code" in ev.event) {
|
||
// Note other close code are handled internally by the library
|
||
if (ev.event.code === EditorUpdateError.code) {
|
||
provider.shouldConnect = false;
|
||
}
|
||
|
||
ui.setMultiplayerStatus("disconnected", ev.event.code);
|
||
|
||
if (ev.event.code === EditorUpdateError.code) {
|
||
setEditorVersionBehind(true);
|
||
}
|
||
}
|
||
});
|
||
|
||
if (debug) {
|
||
provider.on("close", (ev: MessageEvent) =>
|
||
Logger.debug("collaboration", "close", ev)
|
||
);
|
||
provider.on("message", (ev: MessageEvent) =>
|
||
Logger.debug("collaboration", "incoming", {
|
||
message: ev.message,
|
||
})
|
||
);
|
||
provider.on("outgoingMessage", (ev: MessageEvent) =>
|
||
Logger.debug("collaboration", "outgoing", {
|
||
message: ev.message,
|
||
})
|
||
);
|
||
localProvider?.on("synced", () =>
|
||
Logger.debug("collaboration", "local synced")
|
||
);
|
||
}
|
||
|
||
provider.on("status", (ev: ConnectionStatusEvent) => {
|
||
if (ui.multiplayerStatus !== ev.status) {
|
||
ui.setMultiplayerStatus(ev.status, undefined);
|
||
}
|
||
});
|
||
|
||
setRemoteProvider(provider);
|
||
|
||
return () => {
|
||
window.removeEventListener("click", finishObserving);
|
||
window.removeEventListener("wheel", finishObserving);
|
||
window.removeEventListener("scroll", syncScrollPosition);
|
||
provider?.destroy();
|
||
void localProvider?.destroy();
|
||
setRemoteProvider(undefined);
|
||
ui.setMultiplayerStatus(undefined, undefined);
|
||
};
|
||
}, [
|
||
history,
|
||
t,
|
||
documentId,
|
||
ui,
|
||
presence,
|
||
ydoc,
|
||
currentUser.id,
|
||
isMounted,
|
||
auth,
|
||
]);
|
||
|
||
const user = useMemo(
|
||
() => ({
|
||
id: currentUser.id,
|
||
name: currentUser.name,
|
||
color: currentUser.color,
|
||
}),
|
||
[currentUser.id, currentUser.color, currentUser.name]
|
||
);
|
||
|
||
const extensions = useMemo(() => {
|
||
if (!remoteProvider) {
|
||
return props.extensions;
|
||
}
|
||
|
||
return [
|
||
...(props.extensions || []),
|
||
new MultiplayerExtension({
|
||
user,
|
||
provider: remoteProvider,
|
||
document: ydoc,
|
||
}),
|
||
];
|
||
}, [remoteProvider, user, ydoc, props.extensions]);
|
||
|
||
useEffect(() => {
|
||
if ((!hasLocalPersistence || isLocalSynced) && isRemoteSynced) {
|
||
void onSynced?.();
|
||
}
|
||
}, [onSynced, hasLocalPersistence, isLocalSynced, isRemoteSynced]);
|
||
|
||
// Disconnect the realtime connection while idle. `isIdle` also checks for
|
||
// page visibility and will immediately disconnect when a tab is hidden.
|
||
useEffect(() => {
|
||
if (!remoteProvider) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
isIdle &&
|
||
!isVisible &&
|
||
remoteProvider.status === WebSocketStatus.Connected
|
||
) {
|
||
void remoteProvider.disconnect();
|
||
}
|
||
|
||
if (
|
||
(!isIdle || isVisible) &&
|
||
remoteProvider.status === WebSocketStatus.Disconnected
|
||
) {
|
||
void remoteProvider.connect();
|
||
}
|
||
}, [remoteProvider, isIdle, isVisible]);
|
||
|
||
// Certain emoji combinations trigger this error in YJS, while waiting for a fix
|
||
// we must prevent the user from continuing to edit as their changes will not
|
||
// be persisted. See: https://github.com/yjs/yjs/issues/303
|
||
useEffect(() => {
|
||
function onUnhandledError(event: ErrorEvent) {
|
||
if (event.message.includes("URIError: URI malformed")) {
|
||
toast.error(
|
||
t(
|
||
"Sorry, the last change could not be persisted – please reload the page"
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
window.addEventListener("error", onUnhandledError);
|
||
return () => window.removeEventListener("error", onUnhandledError);
|
||
}, [t]);
|
||
|
||
if (!remoteProvider) {
|
||
return null;
|
||
}
|
||
|
||
// while the collaborative document is loading, we render a version of the
|
||
// document from the last text cache in read-only mode if we have it.
|
||
const isLocalReady = !hasLocalPersistence || isLocalSynced;
|
||
const showCache = !isLocalReady && !isRemoteSynced;
|
||
|
||
return (
|
||
<>
|
||
{showCache && (
|
||
<Editor
|
||
editorStyle={props.editorStyle}
|
||
embedsDisabled={props.embedsDisabled}
|
||
defaultValue={props.defaultValue}
|
||
extensions={props.extensions}
|
||
scrollTo={props.scrollTo}
|
||
cacheOnly
|
||
readOnly
|
||
ref={ref}
|
||
/>
|
||
)}
|
||
<Editor
|
||
{...props}
|
||
readOnly={props.readOnly || editorVersionBehind}
|
||
value={undefined}
|
||
defaultValue={undefined}
|
||
extensions={extensions}
|
||
ref={showCache ? undefined : ref}
|
||
style={
|
||
showCache
|
||
? {
|
||
height: 0,
|
||
opacity: 0,
|
||
}
|
||
: undefined
|
||
}
|
||
className={showCursorNames ? "show-cursor-names" : undefined}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default forwardRef<SharedEditor, Props>(MultiplayerEditor);
|