Compare commits

...

58 Commits

Author SHA1 Message Date
Tom Moor 35a4dd19e6 lint 2021-06-13 23:17:57 -07:00
Tom Moor 750d9ab4c6 Merge main 2021-06-13 22:11:29 -07:00
Tom Moor d40e60675d Merge develop 2020-12-08 20:47:04 -08:00
Tom Moor 76169c1bf2 guard 2020-11-25 08:52:47 -08:00
Tom Moor 8ed030e2ec refactor 2020-11-20 00:05:25 -08:00
Tom Moor a55163fb00 working refactor, but more to do 2020-11-19 00:11:58 -08:00
Tom Moor b05a36d450 wip: Refactoring editor 2020-11-18 21:33:02 -08:00
Tom Moor dde6c3e443 Relax documents.update endpoint
fix: documents.update should not trigger event if nothing changes
2020-11-18 19:48:57 -08:00
Tom Moor e861884b4e fix: Cannot publish/edit title 2020-11-17 23:16:45 -08:00
Tom Moor 4ca2d3776e fix: url not returned for new docs 2020-11-17 22:49:55 -08:00
Tom Moor 7c0ddf7efb chore: Track whether remote process update in origin 2020-11-17 21:33:08 -08:00
Tom Moor 6842ea4a35 feat: Animated reconnecting state 2020-11-17 20:52:01 -08:00
Tom Moor 7e0ebc6b4e chore: Move back to y-prosemirror trunk 2020-11-17 20:26:32 -08:00
Tom Moor c0a322bc20 snapshots 2020-11-16 22:08:22 -08:00
Tom Moor 769b0225e2 lint 2020-11-16 21:52:34 -08:00
Tom Moor 9bcf5b0292 fix: race, avoid update event if no text changed 2020-11-16 21:25:40 -08:00
Tom Moor 3b4aa02c67 fix: various connection issues 2020-11-16 21:25:16 -08:00
Tom Moor 375d658231 skip backlink title rewriting for now 2020-11-16 21:25:07 -08:00
Tom Moor 70ea77ce01 hook up awareness to UI
fix header disappearing
2020-11-15 20:06:41 -08:00
Tom Moor cea1d808d1 Bump deps 2020-11-15 12:24:01 -08:00
Tom Moor bea8b85cf9 Merge develop 2020-11-15 11:23:04 -08:00
Tom Moor abeccb8a4c stash 2020-11-08 15:58:00 -08:00
Tom Moor c8d3d26044 Merge branch 'develop' of github.com:outline/outline into yjs 2020-11-05 23:05:16 -08:00
Tom Moor ec5a7d79f5 flow 2020-11-05 18:41:22 -08:00
Tom Moor 30d31b35ac fix: Issue with inflating clientIds in pud 2020-11-04 19:12:55 -08:00
Tom Moor 8abf2436dd wip 2020-11-01 19:52:34 -08:00
Tom Moor 4df75bda7b Remove unneeded applyUpdate 2020-11-01 15:52:11 -08:00
Tom Moor 220546c40a fix: Multiplayer cursors in headings 2020-11-01 13:06:13 -08:00
Tom Moor b96ffe59db Merge develop 2020-11-01 13:02:58 -08:00
Tom Moor 2676a7e8cf events 2020-10-26 23:25:19 -07:00
Tom Moor 5e9e4fb028 Merge branch 'develop' into yjs 2020-10-26 21:39:57 -07:00
Tom Moor 551b1620e0 fix: Flag without realtime editing 2020-10-26 19:27:49 -07:00
Tom Moor b8569ed8de remove flag sidebar item for now 2020-10-25 19:37:13 -07:00
Tom Moor 8d1a707dd0 basic offline messaging 2020-10-25 19:36:10 -07:00
Tom Moor 9877cf1f4e install 2020-10-25 19:13:29 -07:00
Tom Moor 50fbcd8d85 Merge develop 2020-10-25 19:11:21 -07:00
Tom Moor 359d228771 lint 2020-10-25 19:10:31 -07:00
Tom Moor 0347620c75 fix: Update collaboratorIds 2020-10-25 14:42:28 -07:00
Tom Moor cb362511a5 Load from db 2020-10-25 10:40:39 -07:00
Tom Moor 700db463fc fix: Awareness state not available if server dies and restarts 2020-10-24 19:46:08 -07:00
Tom Moor a28dfa77ee fix: Race condition when setting up socket listeners 2020-10-24 19:45:51 -07:00
Tom Moor d8bc6515dd race 2020-10-23 10:06:44 -07:00
Tom Moor 0776b78e25 wip 2020-10-19 07:33:43 -07:00
Tom Moor 4256e7ec87 flow 2020-10-18 22:32:04 -07:00
Tom Moor e723124f8f lint 2020-10-18 22:26:36 -07:00
Tom Moor c2fbd78622 flow-types 2020-10-18 22:16:42 -07:00
Tom Moor 17cbeab409 refactor 2020-10-18 21:36:24 -07:00
Tom Moor 37d456a0fb refactor 2020-10-18 15:37:50 -07:00
Tom Moor 48a0ba0dec refactor, resolve memory leak 2020-10-17 17:47:04 -07:00
Tom Moor f454467bf1 event filtering 2020-10-17 13:21:48 -07:00
Tom Moor f21f660543 stash 2020-10-16 16:19:29 -07:00
Tom Moor 50637bc7ce local cache, more brand-like colors 2020-10-15 23:03:05 -07:00
Tom Moor acb61d5e0c Match editing color to avatar and brand 2020-10-15 22:43:42 -07:00
Tom Moor 7dcbaa9c5c cursors 2020-10-15 22:10:30 -07:00
Tom Moor ae1761e517 wip 2020-10-15 20:24:44 -07:00
Tom Moor 7166378c32 Restore old functionality, put new functionality behind flag 2020-10-13 08:43:53 -07:00
Tom Moor 2719321430 refactoring 2020-10-11 23:06:15 -07:00
Tom Moor a7f2c7edb3 rough, but working 2020-10-11 20:54:31 -07:00
49 changed files with 2752 additions and 259 deletions
+4
View File
@@ -11,6 +11,10 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
+61
View File
@@ -245,6 +245,53 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
}
}
.ProseMirror {
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
transition: opacity 100ms ease-in-out;
}
}
}
}
`;
const EditorTooltip = ({ children, ...props }) => (
@@ -262,3 +309,17 @@ const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
));
// > .ProseMirror-yjs-cursor:first-child {
// margin-top: 16px;
// }
// p:first-child,
// h1:first-child,
// h2:first-child,
// h3:first-child,
// h4:first-child,
// h5:first-child,
// h6:first-child {
// margin-top: 16px;
// }
+20
View File
@@ -0,0 +1,20 @@
// @flow
import * as React from "react";
type Props = {|
interval?: number,
|};
export default function LoadingEllipsis({ interval = 750 }: Props) {
const [step, setStep] = React.useState(0);
React.useEffect(() => {
const handle = setInterval(() => {
setStep((step) => (step === 3 ? 0 : step + 1));
}, interval);
return () => clearInterval(handle);
}, [interval]);
return ".".repeat(step);
}
+1 -1
View File
@@ -4,6 +4,6 @@ import useStores from "./useStores";
export default function useCurrentTeam() {
const { auth } = useStores();
invariant(auth.team, "team required");
invariant(auth.team, "Expected to be authenticated");
return auth.team;
}
+23 -1
View File
@@ -76,6 +76,7 @@ export default class Document extends BaseModel {
@computed
get isNew(): boolean {
return (
!!this.publishedAt &&
!this.lastViewedAt &&
differenceInDays(new Date(), new Date(this.createdAt)) < 14
);
@@ -231,6 +232,27 @@ export default class Document extends BaseModel {
this.injectTemplate = true;
};
@action
update = async (options: SaveOptions & { title: string }) => {
if (this.isSaving) return this;
this.isSaving = true;
try {
if (options.lastRevision) {
return await this.store.update({
id: this.id,
title: this.title,
lastRevision: options.lastRevision,
...options,
});
}
throw new Error("Attempting to update without a lastRevision");
} finally {
this.isSaving = false;
}
};
@action
save = async (options: SaveOptions = {}) => {
if (this.isSaving) return this;
@@ -264,7 +286,7 @@ export default class Document extends BaseModel {
});
}
throw new Error("Attempting to update without a lastRevision");
throw new Error("Attempting to save without a lastRevision");
} finally {
this.isSaving = false;
}
+1
View File
@@ -8,6 +8,7 @@ class Team extends BaseModel {
avatarUrl: string;
sharing: boolean;
documentEmbeds: boolean;
multiplayerEditor: boolean;
guestSignin: boolean;
subdomain: ?string;
domain: ?string;
+1
View File
@@ -8,6 +8,7 @@ class User extends BaseModel {
id: string;
name: string;
email: string;
color: string;
isAdmin: boolean;
isViewer: boolean;
lastActiveAt: string;
+55
View File
@@ -0,0 +1,55 @@
// @flow
import { keymap } from "prosemirror-keymap";
import { Extension } from "rich-markdown-editor";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "y-prosemirror";
import * as Y from "yjs";
export default class MultiplayerExtension extends Extension {
get name() {
return "multiplayer";
}
get plugins() {
const { user, provider, doc } = this.options;
const type = doc.get("prosemirror", Y.XmlFragment);
const assignUser = (tr) => {
const clientIds = Array.from(doc.store.clients.keys());
if (
tr.local &&
tr.changed.size > 0 &&
!clientIds.includes(doc.clientID)
) {
const permanentUserData = new Y.PermanentUserData(doc);
permanentUserData.setUserMapping(doc, doc.clientID, user.id);
doc.off("afterTransaction", assignUser);
}
};
provider.awareness.setLocalStateField("user", {
color: user.color,
name: user.name,
id: user.id,
});
doc.on("afterTransaction", assignUser);
return [
ySyncPlugin(type),
yCursorPlugin(provider.awareness),
yUndoPlugin(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
];
}
}
+356
View File
@@ -0,0 +1,356 @@
// Based on example implementation, modified to work with existing sockets
// https://github.com/yjs/y-websocket/blob/master/src/y-websocket.js
// @flow
import * as bc from "lib0/broadcastchannel.js";
import * as decoding from "lib0/decoding.js";
import * as encoding from "lib0/encoding.js";
import * as mutex from "lib0/mutex.js";
import { Observable } from "lib0/observable.js";
import { Socket } from "socket.io-client";
import * as awarenessProtocol from "y-protocols/awareness.js";
import * as syncProtocol from "y-protocols/sync.js";
import * as Y from "yjs";
import {
MESSAGE_SYNC,
MESSAGE_AWARENESS,
MESSAGE_QUERY_AWARENESS,
} from "shared/constants";
const readMessage = (
provider: WebsocketProvider,
buff: Uint8Array,
emitSynced: boolean
): encoding.Encoder => {
const decoder = decoding.createDecoder(buff);
const encoder = encoding.createEncoder();
const messageType = decoding.readVarUint(decoder);
switch (messageType) {
case MESSAGE_SYNC: {
encoding.writeVarUint(encoder, MESSAGE_SYNC);
const syncMessageType = syncProtocol.readSyncMessage(
decoder,
encoder,
provider.doc,
provider
);
if (
emitSynced &&
syncMessageType === syncProtocol.messageYjsSyncStep2 &&
!provider.synced
) {
provider.synced = true;
}
break;
}
case MESSAGE_QUERY_AWARENESS:
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(
provider.awareness,
Array.from(provider.awareness.getStates().keys())
)
);
break;
case MESSAGE_AWARENESS:
awarenessProtocol.applyAwarenessUpdate(
provider.awareness,
decoding.readVarUint8Array(decoder),
provider
);
break;
default:
console.error("Unable to compute message");
return encoder;
}
return encoder;
};
const broadcastMessage = (provider: WebsocketProvider, buff: ArrayBuffer) => {
if (provider.wsconnected) {
provider.wsPublish(buff);
}
if (provider.bcconnected) {
provider.mux(() => {
bc.publish(provider.documentId, buff);
});
}
};
/**
* Websocket Provider for Yjs. Syncs the shared document using socket.io socket
*/
export class WebsocketProvider extends Observable {
constructor(
socket: Socket,
documentId: string,
userId: string,
doc: Y.Doc,
{
awareness = new awarenessProtocol.Awareness(doc),
resyncInterval = 0,
}: {
awareness: awarenessProtocol.Awareness,
resyncInterval: number,
} = {}
) {
super();
this.socket = socket;
this.bcChannel = documentId;
this.documentId = documentId;
this.userId = userId;
this.doc = doc;
this.awareness = awareness;
this.wsconnected = false;
this.bcconnected = false;
this.shouldConnect = true;
this.mux = mutex.createMutex();
this._synced = false;
this._resyncInterval = 0;
if (resyncInterval > 0) {
this._resyncInterval = setInterval(() => {
if (this.ws) {
// resend sync step 1
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.writeSyncStep1(encoder, doc);
this.wsPublish(encoding.toUint8Array(encoder));
}
}, resyncInterval);
}
this.doc.on("update", this._updateHandler);
window.addEventListener("beforeunload", this._unloadHandler);
awareness.on("update", this._awarenessUpdateHandler);
this.connect();
}
_unloadHandler = () => {
awarenessProtocol.removeAwarenessStates(
this.awareness,
[this.doc.clientID],
"window unload"
);
};
_bcSubscriber = (data: ArrayBuffer) => {
this.mux(() => {
const encoder = readMessage(this, new Uint8Array(data), false);
if (encoding.length(encoder) > 1) {
bc.publish(this.bcChannel, encoding.toUint8Array(encoder));
}
});
};
/**
* Listens to Yjs updates and sends them to remote peers (ws and broadcastchannel)
*/
_updateHandler = (update: Uint8Array, origin: any) => {
if (origin !== this || origin === null) {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.writeUpdate(encoder, update);
broadcastMessage(this, encoding.toUint8Array(encoder));
}
};
_awarenessUpdateHandler = ({ added, updated, removed }: any, origin: any) => {
const changedClients = added.concat(updated).concat(removed);
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients)
);
broadcastMessage(this, encoding.toUint8Array(encoder));
};
get synced() {
return this._synced;
}
set synced(state: boolean) {
if (this._synced !== state) {
this._synced = state;
this.emit("sync", [state]);
}
}
destroy() {
if (this._resyncInterval !== 0) {
clearInterval(this._resyncInterval);
}
this.disconnect();
this.awareness.off("update", this._awarenessUpdateHandler);
this.doc.off("update", this._updateHandler);
this.awareness.destroy();
window.removeEventListener("beforeunload", this._unloadHandler);
super.destroy();
}
connectBc() {
if (!this.bcconnected) {
bc.subscribe(this.bcChannel, this._bcSubscriber);
this.bcconnected = true;
}
// send sync step1 to bc
this.mux(() => {
// write sync step 1
const encoderSync = encoding.createEncoder();
encoding.writeVarUint(encoderSync, MESSAGE_SYNC);
syncProtocol.writeSyncStep1(encoderSync, this.doc);
bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync));
// broadcast local state
const encoderState = encoding.createEncoder();
encoding.writeVarUint(encoderState, MESSAGE_SYNC);
syncProtocol.writeSyncStep2(encoderState, this.doc);
bc.publish(this.bcChannel, encoding.toUint8Array(encoderState));
// write queryAwareness
const encoderAwarenessQuery = encoding.createEncoder();
encoding.writeVarUint(encoderAwarenessQuery, MESSAGE_QUERY_AWARENESS);
bc.publish(this.bcChannel, encoding.toUint8Array(encoderAwarenessQuery));
// broadcast local awareness state
const encoderAwarenessState = encoding.createEncoder();
encoding.writeVarUint(encoderAwarenessState, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoderAwarenessState,
awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
this.doc.clientID,
])
);
bc.publish(this.bcChannel, encoding.toUint8Array(encoderAwarenessState));
});
}
disconnectBc() {
// broadcast message with local awareness state set to null (indicating disconnect)
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(
this.awareness,
[this.doc.clientID],
new Map()
)
);
broadcastMessage(this, encoding.toUint8Array(encoder));
if (this.bcconnected) {
bc.unsubscribe(this.bcChannel, this._bcSubscriber);
this.bcconnected = false;
}
}
wsPublish(data: ArrayBuffer) {
if (!data) return;
this.socket.binary(true).emit("sync", {
documentId: this.documentId,
userId: this.userId,
data,
});
}
_wsMessageHandler = (event: {
documentId: string,
userId: string,
data: ArrayBuffer,
}) => {
if (event.documentId === this.documentId) {
const encoder = readMessage(this, new Uint8Array(event.data), true);
if (encoding.length(encoder) > 1) {
this.wsPublish(encoding.toUint8Array(encoder));
}
}
};
_wsCloseHandler = () => {
awarenessProtocol.removeAwarenessStates(
this.awareness,
Array.from(this.awareness.getStates().keys()),
this
);
this.emit("status", [
{
status: "disconnected",
},
]);
};
_wsJoinHandler = (event: { documentId: string, userId: string }) => {
if (event.userId !== this.userId || event.documentId !== this.documentId) {
return;
}
console.log("user.join");
this.awareness.setLocalState({});
this.emit("status", [
{
status: "connected",
},
]);
console.log("writing sync step 1");
// always send sync step 1 when connected
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.writeSyncStep1(encoder, this.doc);
this.wsPublish(encoding.toUint8Array(encoder));
// broadcast local awareness state
if (this.awareness.getLocalState() !== null) {
console.log("broadcast awareness state");
const encoderAwarenessState = encoding.createEncoder();
encoding.writeVarUint(encoderAwarenessState, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoderAwarenessState,
awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
this.doc.clientID,
])
);
this.wsPublish(encoding.toUint8Array(encoderAwarenessState));
}
};
connectWs() {
this.socket.on("document.sync", this._wsMessageHandler);
this.socket.on("disconnect", this._wsCloseHandler);
this.socket.on("user.join", this._wsJoinHandler);
}
disconnectWs() {
this.socket.off("document.sync", this._wsMessageHandler);
this.socket.off("disconnect", this._wsCloseHandler);
this.socket.off("user.join", this._wsJoinHandler);
}
disconnect() {
this.shouldConnect = false;
this.disconnectWs();
this.disconnectBc();
}
connect() {
this.shouldConnect = true;
if (!this.wsconnected) {
this.wsconnected = true;
this.connectWs();
this.connectBc();
}
}
}
+5 -7
View File
@@ -22,10 +22,8 @@ import { matchDocumentSlug as slug } from "utils/routeHelpers";
const SettingsRoutes = React.lazy(() =>
import(/* webpackChunkName: "settings" */ "./settings")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const Document = React.lazy(() =>
import(/* webpackChunkName: "document" */ "scenes/Document")
);
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
@@ -66,10 +64,10 @@ export default function AuthenticatedRoutes() {
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
+4 -6
View File
@@ -12,10 +12,8 @@ const Authenticated = React.lazy(() =>
const AuthenticatedRoutes = React.lazy(() =>
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const SharedDocument = React.lazy(() =>
import(/* webpackChunkName: "shared-document" */ "scenes/Document/Shared")
);
const Login = React.lazy(() =>
import(/* webpackChunkName: "login" */ "scenes/Login")
@@ -37,11 +35,11 @@ export default function Routes() {
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Route exact path="/share/:shareId" component={SharedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={KeyedDocument}
component={SharedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
+2
View File
@@ -2,6 +2,7 @@
import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import Details from "scenes/Settings/Details";
import Features from "scenes/Settings/Features";
import Groups from "scenes/Settings/Groups";
import ImportExport from "scenes/Settings/ImportExport";
import Notifications from "scenes/Settings/Notifications";
@@ -19,6 +20,7 @@ export default function SettingsRoutes() {
<Switch>
<Route exact path="/settings" component={Profile} />
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/features" component={Features} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/members" component={People} />
<Route exact path="/settings/groups" component={Groups} />
-25
View File
@@ -1,25 +0,0 @@
// @flow
import { inject } from "mobx-react";
import * as React from "react";
import DataLoader from "./components/DataLoader";
class KeyedDocument extends React.Component<*> {
componentWillUnmount() {
this.props.ui.clearActiveDocument();
}
render() {
const { documentSlug, revisionId } = this.props.match.params;
// the urlId portion of the url does not include the slugified title
// we only want to force a re-mount of the document component when the
// document changes, not when the title does so only this portion is used
// for the key.
const urlParts = documentSlug ? documentSlug.split("-") : [];
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
return <DataLoader key={[urlId, revisionId].join("/")} {...this.props} />;
}
}
export default inject("ui")(KeyedDocument);
+61
View File
@@ -0,0 +1,61 @@
// @flow
import * as React from "react";
import { type Match } from "react-router-dom";
import { useTheme } from "styled-components";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import useStores from "../../hooks/useStores";
import Document from "./components/Document";
import Loading from "./components/Loading";
import { type LocationWithState } from "types";
import { OfflineError } from "utils/errors";
type Props = {|
match: Match,
location: LocationWithState,
|};
export default function SharedEditor(props: Props) {
const theme = useTheme();
const [response, setResponse] = React.useState();
const [error, setError] = React.useState<?Error>();
const { documents } = useStores();
const { shareId, documentSlug } = props.match.params;
// ensure the wider page color always matches the theme
React.useEffect(() => {
window.document.body.style.background = theme.background;
}, [theme]);
React.useEffect(() => {
async function fetchData() {
try {
const response = await documents.fetch(documentSlug, {
shareId,
});
setResponse(response);
} catch (err) {
setError(err);
}
}
fetchData();
}, [documents, documentSlug, shareId]);
if (error) {
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
}
if (!response) {
return <Loading location={props.location} />;
}
return (
<Document
document={response.document}
sharedTree={response.sharedTree}
location={props.location}
shareId={shareId}
readOnly
/>
);
}
+21 -16
View File
@@ -18,10 +18,8 @@ import Document from "models/Document";
import Revision from "models/Revision";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
import SocketPresence from "./SocketPresence";
import { type LocationWithState, type NavigationNode } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
@@ -29,6 +27,7 @@ import { isInternalUrl } from "utils/urls";
type Props = {|
match: Match,
location: LocationWithState,
auth: AuthStore,
shares: SharesStore,
documents: DocumentsStore,
policies: PoliciesStore,
@@ -36,6 +35,7 @@ type Props = {|
auth: AuthStore,
ui: UiStore,
history: RouterHistory,
children: (any) => React.Node,
|};
const sharedTreeCache = {};
@@ -223,7 +223,7 @@ class DataLoader extends React.Component<Props> {
};
render() {
const { location, policies, ui } = this.props;
const { location, policies, auth, ui } = this.props;
if (this.error) {
return this.error instanceof OfflineError ? (
@@ -233,10 +233,11 @@ class DataLoader extends React.Component<Props> {
);
}
const team = auth.team;
const document = this.document;
const revision = this.revision;
if (!document) {
if (!document || !team) {
return (
<>
<Loading location={location} />
@@ -246,21 +247,25 @@ class DataLoader extends React.Component<Props> {
}
const abilities = policies.abilities(document.id);
const key = team.multiplayerEditor
? ""
: this.isEditing
? "editing"
: "read-only";
return (
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
<React.Fragment key={key}>
{this.isEditing && <HideSidebar ui={ui} />}
<DocumentComponent
document={document}
revision={revision}
abilities={abilities}
location={location}
readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
sharedTree={this.sharedTree}
/>
</SocketPresence>
{this.props.children({
document,
revision,
abilities,
readOnly: !this.isEditing || !abilities.update || document.isArchived,
onSearchLink: this.onSearchLink,
onCreateLink: this.onCreateLink,
sharedTree: this.sharedTree,
})}
</React.Fragment>
);
}
}
+60 -10
View File
@@ -6,9 +6,10 @@ import { InputIcon } from "outline-icons";
import * as React from "react";
import keydown from "react-keydown";
import { Prompt, Route, withRouter } from "react-router-dom";
import type { RouterHistory, Match } from "react-router-dom";
import type { RouterHistory } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Y from "yjs";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
@@ -17,6 +18,7 @@ import DocumentMove from "scenes/DocumentMove";
import Branding from "components/Branding";
import ErrorBoundary from "components/ErrorBoundary";
import Flex from "components/Flex";
import LoadingEllipsis from "components/LoadingEllipsis";
import LoadingIndicator from "components/LoadingIndicator";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import Modal from "components/Modal";
@@ -31,6 +33,7 @@ import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import MarkAsViewed from "./MarkAsViewed";
import PublicReferences from "./PublicReferences";
import References from "./References";
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
import { type LocationWithState, type NavigationNode, type Theme } from "types";
import { isCustomDomain } from "utils/domains";
import { emojiToUrl } from "utils/emoji";
@@ -54,7 +57,6 @@ Are you sure you want to discard them?
`;
type Props = {
match: Match,
history: RouterHistory,
location: LocationWithState,
sharedTree: ?NavigationNode,
@@ -62,6 +64,14 @@ type Props = {
document: Document,
revision: Revision,
readOnly: boolean,
isShare?: boolean,
multiplayer: {
isConnected: boolean,
isReconnecting: boolean,
isRemoteSynced: boolean,
provider: ?WebsocketProvider,
doc: Y.Doc,
},
onCreateLink: (title: string) => Promise<string>,
onSearchLink: (term: string) => any,
theme: Theme,
@@ -194,7 +204,7 @@ class DocumentScene extends React.Component<Props> {
autosave?: boolean,
} = {}
) => {
const { document } = this.props;
const { document, auth } = this.props;
// prevent saves when we are already saving
if (document.isSaving) return;
@@ -222,10 +232,22 @@ class DocumentScene extends React.Component<Props> {
this.isPublishing = !!options.publish;
try {
const savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
let savedDocument = document;
if (auth.team && auth.team.multiplayerEditor) {
// update does not send "text" field to the API, this is a workaround
// while the multiplayer editor is toggleable. Once it's finalized
// this can be cleaned up to single code path
savedDocument = await document.update({
...options,
lastRevision: this.lastRevision,
});
} else {
savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
}
this.isDirty = false;
this.lastRevision = savedDocument.revision;
@@ -270,6 +292,11 @@ class DocumentScene extends React.Component<Props> {
};
onChange = (getEditorText) => {
const { auth } = this.props;
if (auth.team && auth.team.multiplayerEditor) {
return;
}
this.getEditorText = getEditorText;
// document change while read only is presumed to be a checkbox edit,
@@ -298,9 +325,10 @@ class DocumentScene extends React.Component<Props> {
document,
revision,
readOnly,
abilities,
abilities = {},
auth,
ui,
multiplayer,
match,
} = this.props;
const team = auth.team;
@@ -326,7 +354,7 @@ class DocumentScene extends React.Component<Props> {
auto
>
<Route
path={`${match.url}/move`}
path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
@@ -350,7 +378,12 @@ class DocumentScene extends React.Component<Props> {
{!readOnly && (
<>
<Prompt
when={this.isDirty && !this.isUploading}
when={
this.isDirty &&
!this.isUploading &&
!!team &&
!team.multiplayerEditor
}
message={DISCARD_CHANGES}
/>
<Prompt
@@ -409,12 +442,28 @@ class DocumentScene extends React.Component<Props> {
)}
</Notice>
)}
{team &&
multiplayer &&
!multiplayer.isConnected &&
team.multiplayerEditor && (
<Notice muted>
Connection lost. Any edits will sync once youre back
online.{" "}
{multiplayer.isReconnecting && (
<>
Trying to reconnect
<LoadingEllipsis />
</>
)}
</Notice>
)}
<React.Suspense fallback={<LoadingPlaceholder />}>
<Flex auto={!readOnly}>
{showContents && <Contents headings={headings} />}
<Editor
id={document.id}
innerRef={this.editor}
canShowHoverPreviews={!isShare}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
@@ -436,6 +485,7 @@ class DocumentScene extends React.Component<Props> {
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
ui={this.props.ui}
multiplayer={this.props.multiplayer}
>
{shareId && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
+21 -5
View File
@@ -5,6 +5,7 @@ import * as React from "react";
import Textarea from "react-autosize-textarea";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Y from "yjs";
import { MAX_TITLE_LENGTH } from "shared/constants";
import { light } from "shared/styles/theme";
import parseTitle from "shared/utils/parseTitle";
@@ -15,6 +16,8 @@ import Editor, { type Props as EditorProps } from "components/Editor";
import Flex from "components/Flex";
import HoverPreview from "components/HoverPreview";
import Star, { AnimatedStar } from "components/Star";
import MultiplayerEditor from "./MultiplayerEditor";
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
import { isModKey } from "utils/keyboard";
import { documentHistoryUrl } from "utils/routeHelpers";
@@ -24,9 +27,18 @@ type Props = {|
title: string,
document: Document,
isDraft: boolean,
shareId: ?string,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
canShowHoverPreviews?: boolean,
readOnly?: boolean,
onSave: ({ publish?: boolean, done?: boolean, autosave?: boolean }) => mixed,
innerRef: { current: any },
multiplayer: {
isConnected: boolean,
isReconnecting: boolean,
isRemoteSynced: boolean,
provider: ?WebsocketProvider,
doc: Y.Doc,
},
shareId: ?string,
children: React.Node,
|};
@@ -97,15 +109,18 @@ class DocumentEditor extends React.Component<Props> {
title,
onChangeTitle,
isDraft,
shareId,
canShowHoverPreviews,
readOnly,
innerRef,
multiplayer,
shareId,
children,
...rest
} = this.props;
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
const normalizedTitle =
!title && readOnly ? document.titleWithDefault : title;
@@ -139,19 +154,20 @@ class DocumentEditor extends React.Component<Props> {
to={documentHistoryUrl(document)}
/>
)}
<Editor
<EditorComponent
ref={innerRef}
autoFocus={!!title && !this.props.defaultValue}
placeholder="…the rest is up to you"
onHoverLink={this.handleLinkActive}
scrollTo={window.location.hash}
readOnly={readOnly}
multiplayer={multiplayer}
shareId={shareId}
grow
{...rest}
/>
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
{this.activeLinkEvent && !shareId && readOnly && (
{this.activeLinkEvent && canShowHoverPreviews && readOnly && (
<HoverPreview
node={this.activeLinkEvent.target}
event={this.activeLinkEvent}
@@ -0,0 +1,63 @@
// @flow
import * as React from "react";
import * as Y from "yjs";
import Editor from "components/Editor";
import useCurrentUser from "hooks/useCurrentUser";
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
type Props = {
multiplayer: {
isConnected: boolean,
isReconnecting: boolean,
isRemoteSynced: boolean,
provider: ?WebsocketProvider,
doc: Y.Doc,
},
};
export default function MultiplayerEditor({ multiplayer, ...props }: Props) {
const user = useCurrentUser();
const [showCachedDocument, setShowCachedDocument] = React.useState(true);
const { provider, doc, isRemoteSynced } = multiplayer;
React.useEffect(() => {
if (isRemoteSynced) {
setTimeout(() => setShowCachedDocument(false), 100);
}
}, [showCachedDocument, isRemoteSynced]);
const extensions = React.useMemo(() => {
console.log("extensions");
return [
new MultiplayerExtension({
user,
provider,
doc,
}),
];
}, [user, provider, doc]);
return (
<span style={{ position: "relative" }}>
{isRemoteSynced && (
<Editor
{...props}
key="multiplayer"
defaultValue={undefined}
value={undefined}
extensions={extensions}
style={{ position: "absolute", width: "100%" }}
/>
)}
{showCachedDocument && (
<Editor
{...props}
style={{ position: "absolute", width: "100%" }}
readOnly
/>
)}
</span>
);
}
@@ -1,77 +1,113 @@
// @flow
import * as React from "react";
import { USER_PRESENCE_INTERVAL } from "shared/constants";
import * as Y from "yjs";
import { SocketContext } from "components/SocketProvider";
import useStores from "hooks/useStores";
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
type Props = {
children?: React.Node,
children: ({
provider: ?WebsocketProvider,
isReconnecting: boolean,
isConnected: boolean,
doc: Y.Doc,
}) => React.Node,
isMultiplayer: boolean,
documentId: string,
isEditing: boolean,
userId?: string,
};
export default class SocketPresence extends React.Component<Props> {
static contextType = SocketContext;
previousContext: any;
editingInterval: IntervalID;
export default function SocketPresence(props: Props) {
const { presence } = useStores();
const context = React.useContext(SocketContext);
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
const [isConnected, setConnected] = React.useState(
context ? context.connected : false
);
const [isReconnecting, setReconnecting] = React.useState(false);
const [doc] = React.useState(() =>
props.isMultiplayer ? new Y.Doc() : undefined
);
const [provider] = React.useState(() =>
props.isMultiplayer && props.userId
? new WebsocketProvider(context, props.documentId, props.userId, doc)
: undefined
);
componentDidMount() {
this.editingInterval = setInterval(() => {
if (this.props.isEditing) {
this.emitPresence();
if (provider) {
provider.once("sync", () => setRemoteSynced(true));
}
React.useEffect(() => {
return () => {
if (provider) {
provider.destroy();
}
}, USER_PRESENCE_INTERVAL);
this.setupOnce();
}
};
}, []);
componentDidUpdate(prevProps: Props) {
this.setupOnce();
const awareness = provider && provider.awareness;
React.useEffect(() => {
const onUpdate = () => {
presence.updateFromAwareness(props.documentId, awareness);
};
if (prevProps.isEditing !== this.props.isEditing) {
this.emitPresence();
}
}
componentWillUnmount() {
if (this.context) {
this.context.emit("leave", { documentId: this.props.documentId });
this.context.off("authenticated", this.emitJoin);
if (awareness) {
awareness.on("update", onUpdate);
}
clearInterval(this.editingInterval);
}
setupOnce = () => {
if (this.context && this.context !== this.previousContext) {
this.previousContext = this.context;
if (this.context.authenticated) {
this.emitJoin();
return () => {
if (awareness) {
awareness.off("update", onUpdate);
}
this.context.on("authenticated", () => {
this.emitJoin();
});
};
}, [presence, props.documentId, awareness]);
React.useEffect(() => {
if (!context) return;
const emitJoin = () => {
if (!context) return;
context.emit("join", { documentId: props.documentId });
};
const updateStatus = () => {
setConnected(context.connected);
};
const reconnectingStopped = () => {
setReconnecting(false);
};
context.on("connect", updateStatus);
context.on("disconnect", updateStatus);
context.on("reconnect", reconnectingStopped);
context.on("reconnect_attempt", setReconnecting);
context.on("reconnect_failed", reconnectingStopped);
context.on("authenticated", emitJoin);
if (context.authenticated) {
emitJoin();
}
};
emitJoin = () => {
if (!this.context) return;
return () => {
if (!context) return;
this.context.emit("join", {
documentId: this.props.documentId,
isEditing: this.props.isEditing,
});
};
context.emit("leave", { documentId: props.documentId });
context.off("authenticated", emitJoin);
context.off("connect", updateStatus);
context.off("disconnect", updateStatus);
context.off("reconnect", reconnectingStopped);
context.off("reconnect_attempt", setReconnecting);
context.off("reconnect_failed", reconnectingStopped);
};
}, [context, props.documentId, props.userId]);
emitPresence = () => {
if (!this.context) return;
this.context.emit("presence", {
documentId: this.props.documentId,
isEditing: this.props.isEditing,
});
};
render() {
return this.props.children || null;
}
return props.children({
isConnected,
isRemoteSynced,
isReconnecting,
provider,
doc,
});
}
+64 -1
View File
@@ -1,3 +1,66 @@
// @flow
import * as React from "react";
import { type Match } from "react-router-dom";
import DataLoader from "./components/DataLoader";
export default DataLoader;
import Document from "./components/Document";
import SocketPresence from "./components/SocketPresence";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import { type LocationWithState } from "types";
type Props = {|
location: LocationWithState,
match: Match,
|};
export default function DocumentScene(props: Props) {
const { ui } = useStores();
const user = useCurrentUser();
const team = useCurrentTeam();
React.useEffect(() => {
return () => ui.clearActiveDocument();
}, [ui]);
const { documentSlug, revisionId } = props.match.params;
// the urlId portion of the url does not include the slugified title
// we only want to force a re-mount of the document component when the
// document changes, not when the title does so only this portion is used
// for the key.
const urlParts = documentSlug ? documentSlug.split("-") : [];
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
const key = [urlId, revisionId].join("/");
const isMultiplayer = team.multiplayerEditor;
return (
<DataLoader key={key} match={props.match}>
{({ document, ...rest }) => {
const isActive =
!document.isArchived && !document.isDeleted && !revisionId;
if (isActive) {
return (
<SocketPresence
documentId={document.id}
userId={user.id}
isMultiplayer={isMultiplayer}
>
{(multiplayer) => (
<Document
document={document}
match={props.match}
multiplayer={multiplayer}
{...rest}
/>
)}
</SocketPresence>
);
}
return <Document document={document} match={props.match} {...rest} />;
}}
</DataLoader>
);
}
+70
View File
@@ -0,0 +1,70 @@
// @flow
import { debounce } from "lodash";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import CenteredContent from "components/CenteredContent";
import Checkbox from "components/Checkbox";
import HelpText from "components/HelpText";
import PageTitle from "components/PageTitle";
type Props = {
auth: AuthStore,
ui: UiStore,
};
@observer
class Features extends React.Component<Props> {
form: ?HTMLFormElement;
@observable multiplayerEditor: boolean;
componentDidMount() {
const { auth } = this.props;
if (auth.team) {
this.multiplayerEditor = auth.team.multiplayerEditor;
}
}
handleChange = async (ev: SyntheticInputEvent<*>) => {
switch (ev.target.name) {
case "multiplayerEditor":
this.multiplayerEditor = ev.target.checked;
break;
default:
}
await this.props.auth.updateTeam({
multiplayerEditor: this.multiplayerEditor,
});
this.showSuccessMessage();
};
showSuccessMessage = debounce(() => {
this.props.ui.showToast("Settings saved");
}, 500);
render() {
return (
<CenteredContent>
<PageTitle title="Labs" />
<h1>Labs</h1>
<HelpText>
Enable experimental features that are still under development.
</HelpText>
<Checkbox
label="Multiplayer editor"
name="multiplayerEditor"
checked={this.multiplayerEditor}
onChange={this.handleChange}
note="Allow multiple team members to edit documents at the same time"
/>
</CenteredContent>
);
}
}
export default inject("auth", "ui")(Features);
+26
View File
@@ -34,6 +34,32 @@ export default class PresenceStore {
this.data.set(documentId, existing);
}
@action updateFromAwareness(documentId: string, awareness: any) {
const existing = this.data.get(documentId) || new Map();
const clients = Array.from(awareness.states.values());
const userIds = clients.map((client) => client.user && client.user.id);
existing.forEach((value, key) => {
if (!userIds.includes(key)) {
existing.delete(key);
}
});
clients.forEach((client) => {
if (!client.user) {
return;
}
const userId = client.user.id;
existing.set(userId, {
isEditing: !!client.cursor,
userId,
});
});
this.data.set(documentId, existing);
}
// called when a user presence message is received user.presence websocket
// message.
// While in edit mode a message is sent every USER_PRESENCE_INTERVAL, if
+1 -1
View File
@@ -603,7 +603,7 @@ export default class DocumentsStore extends BaseStore<Document> {
async update(params: {
id: string,
title: string,
text: string,
text?: string,
lastRevision: number,
}) {
const document = await super.update(params);
+377
View File
@@ -0,0 +1,377 @@
// flow-typed signature: 97da878aea98698d6c06f8a696bb62af
// flow-typed version: <<STUB>>/lib0_v0.2.34/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'lib0'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
// @flow
declare module "lib0" {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module "lib0/array" {
declare module.exports: any;
}
declare module "lib0/bin/gendocs" {
declare module.exports: any;
}
declare module "lib0/binary" {
declare module.exports: any;
}
declare module "lib0/broadcastchannel" {
declare module.exports: any;
}
declare module "lib0/buffer" {
declare module.exports: any;
}
declare module "lib0/component" {
declare module.exports: any;
}
declare module "lib0/conditions" {
declare module.exports: any;
}
declare module "lib0/decoding" {
declare module.exports: any;
}
declare module "lib0/diff" {
declare module.exports: any;
}
declare module "lib0/dist/test" {
declare module.exports: any;
}
declare module "lib0/dom" {
declare module.exports: any;
}
declare module "lib0/encoding" {
declare module.exports: any;
}
declare module "lib0/environment" {
declare module.exports: any;
}
declare module "lib0/error" {
declare module.exports: any;
}
declare module "lib0/eventloop" {
declare module.exports: any;
}
declare module "lib0/function" {
declare module.exports: any;
}
declare module "lib0/indexeddb" {
declare module.exports: any;
}
declare module "lib0/isomorphic" {
declare module.exports: any;
}
declare module "lib0/iterator" {
declare module.exports: any;
}
declare module "lib0/json" {
declare module.exports: any;
}
declare module "lib0/logging" {
declare module.exports: any;
}
declare module "lib0/map" {
declare module.exports: any;
}
declare module "lib0/math" {
declare module.exports: any;
}
declare module "lib0/metric" {
declare module.exports: any;
}
declare module "lib0/mutex" {
declare module.exports: any;
}
declare module "lib0/number" {
declare module.exports: any;
}
declare module "lib0/object" {
declare module.exports: any;
}
declare module "lib0/observable" {
declare module.exports: any;
}
declare module "lib0/pair" {
declare module.exports: any;
}
declare module "lib0/prng" {
declare module.exports: any;
}
declare module "lib0/prng/Mt19937" {
declare module.exports: any;
}
declare module "lib0/prng/Xoroshiro128plus" {
declare module.exports: any;
}
declare module "lib0/prng/Xorshift32" {
declare module.exports: any;
}
declare module "lib0/promise" {
declare module.exports: any;
}
declare module "lib0/queue" {
declare module.exports: any;
}
declare module "lib0/random" {
declare module.exports: any;
}
declare module "lib0/set" {
declare module.exports: any;
}
declare module "lib0/sort" {
declare module.exports: any;
}
declare module "lib0/statistics" {
declare module.exports: any;
}
declare module "lib0/storage" {
declare module.exports: any;
}
declare module "lib0/string" {
declare module.exports: any;
}
declare module "lib0/symbol" {
declare module.exports: any;
}
declare module "lib0/test" {
declare module.exports: any;
}
declare module "lib0/testing" {
declare module.exports: any;
}
declare module "lib0/time" {
declare module.exports: any;
}
declare module "lib0/tree" {
declare module.exports: any;
}
declare module "lib0/url" {
declare module.exports: any;
}
declare module "lib0/websocket" {
declare module.exports: any;
}
// Filename aliases
declare module "lib0/array.js" {
declare module.exports: $Exports<"lib0/array">;
}
declare module "lib0/bin/gendocs.js" {
declare module.exports: $Exports<"lib0/bin/gendocs">;
}
declare module "lib0/binary.js" {
declare module.exports: $Exports<"lib0/binary">;
}
declare module "lib0/broadcastchannel.js" {
declare module.exports: $Exports<"lib0/broadcastchannel">;
}
declare module "lib0/buffer.js" {
declare module.exports: $Exports<"lib0/buffer">;
}
declare module "lib0/component.js" {
declare module.exports: $Exports<"lib0/component">;
}
declare module "lib0/conditions.js" {
declare module.exports: $Exports<"lib0/conditions">;
}
declare module "lib0/decoding.js" {
declare module.exports: $Exports<"lib0/decoding">;
}
declare module "lib0/dist/decoding.cjs" {
declare module.exports: $Exports<"lib0/decoding">;
}
declare module "lib0/diff.js" {
declare module.exports: $Exports<"lib0/diff">;
}
declare module "lib0/dist/test.js" {
declare module.exports: $Exports<"lib0/dist/test">;
}
declare module "lib0/dom.js" {
declare module.exports: $Exports<"lib0/dom">;
}
declare module "lib0/encoding.js" {
declare module.exports: $Exports<"lib0/encoding">;
}
declare module "lib0/dist/encoding.cjs" {
declare module.exports: $Exports<"lib0/encoding">;
}
declare module "lib0/environment.js" {
declare module.exports: $Exports<"lib0/environment">;
}
declare module "lib0/error.js" {
declare module.exports: $Exports<"lib0/error">;
}
declare module "lib0/eventloop.js" {
declare module.exports: $Exports<"lib0/eventloop">;
}
declare module "lib0/function.js" {
declare module.exports: $Exports<"lib0/function">;
}
declare module "lib0/index" {
declare module.exports: $Exports<"lib0">;
}
declare module "lib0/index.js" {
declare module.exports: $Exports<"lib0">;
}
declare module "lib0/indexeddb.js" {
declare module.exports: $Exports<"lib0/indexeddb">;
}
declare module "lib0/isomorphic.js" {
declare module.exports: $Exports<"lib0/isomorphic">;
}
declare module "lib0/iterator.js" {
declare module.exports: $Exports<"lib0/iterator">;
}
declare module "lib0/json.js" {
declare module.exports: $Exports<"lib0/json">;
}
declare module "lib0/logging.js" {
declare module.exports: $Exports<"lib0/logging">;
}
declare module "lib0/map.js" {
declare module.exports: $Exports<"lib0/map">;
}
declare module "lib0/math.js" {
declare module.exports: $Exports<"lib0/math">;
}
declare module "lib0/metric.js" {
declare module.exports: $Exports<"lib0/metric">;
}
declare module "lib0/mutex.js" {
declare module.exports: $Exports<"lib0/mutex">;
}
declare module "lib0/dist/mutex.cjs" {
declare module.exports: $Exports<"lib0/mutex">;
}
declare module "lib0/number.js" {
declare module.exports: $Exports<"lib0/number">;
}
declare module "lib0/object.js" {
declare module.exports: $Exports<"lib0/object">;
}
declare module "lib0/observable.js" {
declare module.exports: $Exports<"lib0/observable">;
}
declare module "lib0/pair.js" {
declare module.exports: $Exports<"lib0/pair">;
}
declare module "lib0/prng.js" {
declare module.exports: $Exports<"lib0/prng">;
}
declare module "lib0/prng/Mt19937.js" {
declare module.exports: $Exports<"lib0/prng/Mt19937">;
}
declare module "lib0/prng/Xoroshiro128plus.js" {
declare module.exports: $Exports<"lib0/prng/Xoroshiro128plus">;
}
declare module "lib0/prng/Xorshift32.js" {
declare module.exports: $Exports<"lib0/prng/Xorshift32">;
}
declare module "lib0/promise.js" {
declare module.exports: $Exports<"lib0/promise">;
}
declare module "lib0/queue.js" {
declare module.exports: $Exports<"lib0/queue">;
}
declare module "lib0/random.js" {
declare module.exports: $Exports<"lib0/random">;
}
declare module "lib0/set.js" {
declare module.exports: $Exports<"lib0/set">;
}
declare module "lib0/sort.js" {
declare module.exports: $Exports<"lib0/sort">;
}
declare module "lib0/statistics.js" {
declare module.exports: $Exports<"lib0/statistics">;
}
declare module "lib0/storage.js" {
declare module.exports: $Exports<"lib0/storage">;
}
declare module "lib0/string.js" {
declare module.exports: $Exports<"lib0/string">;
}
declare module "lib0/symbol.js" {
declare module.exports: $Exports<"lib0/symbol">;
}
declare module "lib0/test.js" {
declare module.exports: $Exports<"lib0/test">;
}
declare module "lib0/testing.js" {
declare module.exports: $Exports<"lib0/testing">;
}
declare module "lib0/time.js" {
declare module.exports: $Exports<"lib0/time">;
}
declare module "lib0/tree.js" {
declare module.exports: $Exports<"lib0/tree">;
}
declare module "lib0/url.js" {
declare module.exports: $Exports<"lib0/url">;
}
declare module "lib0/websocket.js" {
declare module.exports: $Exports<"lib0/websocket">;
}
+39
View File
@@ -0,0 +1,39 @@
// flow-typed signature: 71e55e30d387153cf804d226f95c0ad8
// flow-typed version: <<STUB>>/y-indexeddb_v^9.0.5/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'y-indexeddb'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
declare module 'y-indexeddb' {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'y-indexeddb/dist/test' {
declare module.exports: any;
}
declare module 'y-indexeddb/src/y-indexeddb' {
declare module.exports: any;
}
// Filename aliases
declare module 'y-indexeddb/dist/test.js' {
declare module.exports: $Exports<'y-indexeddb/dist/test'>;
}
declare module 'y-indexeddb/src/y-indexeddb.js' {
declare module.exports: $Exports<'y-indexeddb/src/y-indexeddb'>;
}
+68
View File
@@ -0,0 +1,68 @@
// flow-typed signature: 2db53ec5dbb577a4e27bc465cd4670f3
// flow-typed version: <<STUB>>/y-prosemirror_v^0.3.7/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'y-prosemirror'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
// @flow
declare module "y-prosemirror" {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module "y-prosemirror/dist/test" {
declare module.exports: any;
}
declare module "y-prosemirror/src/lib" {
declare module.exports: any;
}
declare module "y-prosemirror/src/plugins/cursor-plugin" {
declare module.exports: any;
}
declare module "y-prosemirror/src/plugins/sync-plugin" {
declare module.exports: any;
}
declare module "y-prosemirror/src/plugins/undo-plugin" {
declare module.exports: any;
}
declare module "y-prosemirror/src/y-prosemirror" {
declare module.exports: any;
}
// Filename aliases
declare module "y-prosemirror/dist/test.js" {
declare module.exports: $Exports<"y-prosemirror/dist/test">;
}
declare module "y-prosemirror/src/lib.js" {
declare module.exports: $Exports<"y-prosemirror/src/lib">;
}
declare module "y-prosemirror/src/plugins/cursor-plugin.js" {
declare module.exports: $Exports<"y-prosemirror/src/plugins/cursor-plugin">;
}
declare module "y-prosemirror/src/plugins/sync-plugin.js" {
declare module.exports: $Exports<"y-prosemirror/src/plugins/sync-plugin">;
}
declare module "y-prosemirror/src/plugins/undo-plugin.js" {
declare module.exports: $Exports<"y-prosemirror/src/plugins/undo-plugin">;
}
declare module "y-prosemirror/src/y-prosemirror.js" {
declare module.exports: $Exports<"y-prosemirror/src/y-prosemirror">;
}
+67
View File
@@ -0,0 +1,67 @@
// flow-typed signature: 3ef5e4dd42591ff15af5f507abd6aa97
// flow-typed version: <<STUB>>/y-protocols_v^1.0.1/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'y-protocols'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
// @flow
declare module "y-protocols" {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module "y-protocols/auth" {
declare module.exports: any;
}
declare module "y-protocols/awareness" {
declare module.exports: any;
}
declare module "y-protocols/awareness.test" {
declare module.exports: any;
}
declare module "y-protocols/dist/test" {
declare module.exports: any;
}
declare module "y-protocols/sync" {
declare module.exports: any;
}
// Filename aliases
declare module "y-protocols/auth.js" {
declare module.exports: $Exports<"y-protocols/auth">;
}
declare module "y-protocols/awareness.js" {
declare module.exports: $Exports<"y-protocols/awareness">;
}
declare module "y-protocols/dist/awareness.cjs" {
declare module.exports: $Exports<"y-protocols/awareness">;
}
declare module "y-protocols/awareness.test.js" {
declare module.exports: $Exports<"y-protocols/awareness.test">;
}
declare module "y-protocols/dist/test.js" {
declare module.exports: $Exports<"y-protocols/dist/test">;
}
declare module "y-protocols/sync.js" {
declare module.exports: $Exports<"y-protocols/sync">;
}
declare module "y-protocols/dist/sync.cjs" {
declare module.exports: $Exports<"y-protocols/sync">;
}
+430
View File
@@ -0,0 +1,430 @@
// flow-typed signature: ec89eac307897bef104c76ce1dd14a4d
// flow-typed version: <<STUB>>/yjs_v^13.4.1/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'yjs'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
declare module 'yjs' {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'yjs/dist/tests' {
declare module.exports: any;
}
declare module 'yjs/docs/scripts/jquery.min' {
declare module.exports: any;
}
declare module 'yjs/docs/scripts/linenumber' {
declare module.exports: any;
}
declare module 'yjs/docs/scripts/prettify/lang-css' {
declare module.exports: any;
}
declare module 'yjs/docs/scripts/prettify/prettify' {
declare module.exports: any;
}
declare module 'yjs/docs/scripts/tui-doc' {
declare module.exports: any;
}
declare module 'yjs/src' {
declare module.exports: any;
}
declare module 'yjs/src/internals' {
declare module.exports: any;
}
declare module 'yjs/src/structs/AbstractStruct' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentAny' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentBinary' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentDeleted' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentDoc' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentEmbed' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentFormat' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentJSON' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentString' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentType' {
declare module.exports: any;
}
declare module 'yjs/src/structs/GC' {
declare module.exports: any;
}
declare module 'yjs/src/structs/Item' {
declare module.exports: any;
}
declare module 'yjs/src/types/AbstractType' {
declare module.exports: any;
}
declare module 'yjs/src/types/YArray' {
declare module.exports: any;
}
declare module 'yjs/src/types/YMap' {
declare module.exports: any;
}
declare module 'yjs/src/types/YText' {
declare module.exports: any;
}
declare module 'yjs/src/types/YXmlElement' {
declare module.exports: any;
}
declare module 'yjs/src/types/YXmlEvent' {
declare module.exports: any;
}
declare module 'yjs/src/types/YXmlFragment' {
declare module.exports: any;
}
declare module 'yjs/src/types/YXmlHook' {
declare module.exports: any;
}
declare module 'yjs/src/types/YXmlText' {
declare module.exports: any;
}
declare module 'yjs/src/utils/AbstractConnector' {
declare module.exports: any;
}
declare module 'yjs/src/utils/DeleteSet' {
declare module.exports: any;
}
declare module 'yjs/src/utils/Doc' {
declare module.exports: any;
}
declare module 'yjs/src/utils/encoding' {
declare module.exports: any;
}
declare module 'yjs/src/utils/EventHandler' {
declare module.exports: any;
}
declare module 'yjs/src/utils/ID' {
declare module.exports: any;
}
declare module 'yjs/src/utils/isParentOf' {
declare module.exports: any;
}
declare module 'yjs/src/utils/logging' {
declare module.exports: any;
}
declare module 'yjs/src/utils/PermanentUserData' {
declare module.exports: any;
}
declare module 'yjs/src/utils/RelativePosition' {
declare module.exports: any;
}
declare module 'yjs/src/utils/Snapshot' {
declare module.exports: any;
}
declare module 'yjs/src/utils/StructStore' {
declare module.exports: any;
}
declare module 'yjs/src/utils/Transaction' {
declare module.exports: any;
}
declare module 'yjs/src/utils/UndoManager' {
declare module.exports: any;
}
declare module 'yjs/src/utils/UpdateDecoder' {
declare module.exports: any;
}
declare module 'yjs/src/utils/UpdateEncoder' {
declare module.exports: any;
}
declare module 'yjs/src/utils/YEvent' {
declare module.exports: any;
}
declare module 'yjs/tests/compatibility.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/doc.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/encoding.tests' {
declare module.exports: any;
}
declare module 'yjs/tests' {
declare module.exports: any;
}
declare module 'yjs/tests/snapshot.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/testHelper' {
declare module.exports: any;
}
declare module 'yjs/tests/undo-redo.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/y-array.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/y-map.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/y-text.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/y-xml.tests' {
declare module.exports: any;
}
// Filename aliases
declare module 'yjs/dist/tests.js' {
declare module.exports: $Exports<'yjs/dist/tests'>;
}
declare module 'yjs/docs/scripts/jquery.min.js' {
declare module.exports: $Exports<'yjs/docs/scripts/jquery.min'>;
}
declare module 'yjs/docs/scripts/linenumber.js' {
declare module.exports: $Exports<'yjs/docs/scripts/linenumber'>;
}
declare module 'yjs/docs/scripts/prettify/lang-css.js' {
declare module.exports: $Exports<'yjs/docs/scripts/prettify/lang-css'>;
}
declare module 'yjs/docs/scripts/prettify/prettify.js' {
declare module.exports: $Exports<'yjs/docs/scripts/prettify/prettify'>;
}
declare module 'yjs/docs/scripts/tui-doc.js' {
declare module.exports: $Exports<'yjs/docs/scripts/tui-doc'>;
}
declare module 'yjs/src/index' {
declare module.exports: $Exports<'yjs/src'>;
}
declare module 'yjs/src/index.js' {
declare module.exports: $Exports<'yjs/src'>;
}
declare module 'yjs/src/internals.js' {
declare module.exports: $Exports<'yjs/src/internals'>;
}
declare module 'yjs/src/structs/AbstractStruct.js' {
declare module.exports: $Exports<'yjs/src/structs/AbstractStruct'>;
}
declare module 'yjs/src/structs/ContentAny.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentAny'>;
}
declare module 'yjs/src/structs/ContentBinary.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentBinary'>;
}
declare module 'yjs/src/structs/ContentDeleted.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentDeleted'>;
}
declare module 'yjs/src/structs/ContentDoc.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentDoc'>;
}
declare module 'yjs/src/structs/ContentEmbed.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentEmbed'>;
}
declare module 'yjs/src/structs/ContentFormat.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentFormat'>;
}
declare module 'yjs/src/structs/ContentJSON.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentJSON'>;
}
declare module 'yjs/src/structs/ContentString.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentString'>;
}
declare module 'yjs/src/structs/ContentType.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentType'>;
}
declare module 'yjs/src/structs/GC.js' {
declare module.exports: $Exports<'yjs/src/structs/GC'>;
}
declare module 'yjs/src/structs/Item.js' {
declare module.exports: $Exports<'yjs/src/structs/Item'>;
}
declare module 'yjs/src/types/AbstractType.js' {
declare module.exports: $Exports<'yjs/src/types/AbstractType'>;
}
declare module 'yjs/src/types/YArray.js' {
declare module.exports: $Exports<'yjs/src/types/YArray'>;
}
declare module 'yjs/src/types/YMap.js' {
declare module.exports: $Exports<'yjs/src/types/YMap'>;
}
declare module 'yjs/src/types/YText.js' {
declare module.exports: $Exports<'yjs/src/types/YText'>;
}
declare module 'yjs/src/types/YXmlElement.js' {
declare module.exports: $Exports<'yjs/src/types/YXmlElement'>;
}
declare module 'yjs/src/types/YXmlEvent.js' {
declare module.exports: $Exports<'yjs/src/types/YXmlEvent'>;
}
declare module 'yjs/src/types/YXmlFragment.js' {
declare module.exports: $Exports<'yjs/src/types/YXmlFragment'>;
}
declare module 'yjs/src/types/YXmlHook.js' {
declare module.exports: $Exports<'yjs/src/types/YXmlHook'>;
}
declare module 'yjs/src/types/YXmlText.js' {
declare module.exports: $Exports<'yjs/src/types/YXmlText'>;
}
declare module 'yjs/src/utils/AbstractConnector.js' {
declare module.exports: $Exports<'yjs/src/utils/AbstractConnector'>;
}
declare module 'yjs/src/utils/DeleteSet.js' {
declare module.exports: $Exports<'yjs/src/utils/DeleteSet'>;
}
declare module 'yjs/src/utils/Doc.js' {
declare module.exports: $Exports<'yjs/src/utils/Doc'>;
}
declare module 'yjs/src/utils/encoding.js' {
declare module.exports: $Exports<'yjs/src/utils/encoding'>;
}
declare module 'yjs/src/utils/EventHandler.js' {
declare module.exports: $Exports<'yjs/src/utils/EventHandler'>;
}
declare module 'yjs/src/utils/ID.js' {
declare module.exports: $Exports<'yjs/src/utils/ID'>;
}
declare module 'yjs/src/utils/isParentOf.js' {
declare module.exports: $Exports<'yjs/src/utils/isParentOf'>;
}
declare module 'yjs/src/utils/logging.js' {
declare module.exports: $Exports<'yjs/src/utils/logging'>;
}
declare module 'yjs/src/utils/PermanentUserData.js' {
declare module.exports: $Exports<'yjs/src/utils/PermanentUserData'>;
}
declare module 'yjs/src/utils/RelativePosition.js' {
declare module.exports: $Exports<'yjs/src/utils/RelativePosition'>;
}
declare module 'yjs/src/utils/Snapshot.js' {
declare module.exports: $Exports<'yjs/src/utils/Snapshot'>;
}
declare module 'yjs/src/utils/StructStore.js' {
declare module.exports: $Exports<'yjs/src/utils/StructStore'>;
}
declare module 'yjs/src/utils/Transaction.js' {
declare module.exports: $Exports<'yjs/src/utils/Transaction'>;
}
declare module 'yjs/src/utils/UndoManager.js' {
declare module.exports: $Exports<'yjs/src/utils/UndoManager'>;
}
declare module 'yjs/src/utils/UpdateDecoder.js' {
declare module.exports: $Exports<'yjs/src/utils/UpdateDecoder'>;
}
declare module 'yjs/src/utils/UpdateEncoder.js' {
declare module.exports: $Exports<'yjs/src/utils/UpdateEncoder'>;
}
declare module 'yjs/src/utils/YEvent.js' {
declare module.exports: $Exports<'yjs/src/utils/YEvent'>;
}
declare module 'yjs/tests/compatibility.tests.js' {
declare module.exports: $Exports<'yjs/tests/compatibility.tests'>;
}
declare module 'yjs/tests/doc.tests.js' {
declare module.exports: $Exports<'yjs/tests/doc.tests'>;
}
declare module 'yjs/tests/encoding.tests.js' {
declare module.exports: $Exports<'yjs/tests/encoding.tests'>;
}
declare module 'yjs/tests/index' {
declare module.exports: $Exports<'yjs/tests'>;
}
declare module 'yjs/tests/index.js' {
declare module.exports: $Exports<'yjs/tests'>;
}
declare module 'yjs/tests/snapshot.tests.js' {
declare module.exports: $Exports<'yjs/tests/snapshot.tests'>;
}
declare module 'yjs/tests/testHelper.js' {
declare module.exports: $Exports<'yjs/tests/testHelper'>;
}
declare module 'yjs/tests/undo-redo.tests.js' {
declare module.exports: $Exports<'yjs/tests/undo-redo.tests'>;
}
declare module 'yjs/tests/y-array.tests.js' {
declare module.exports: $Exports<'yjs/tests/y-array.tests'>;
}
declare module 'yjs/tests/y-map.tests.js' {
declare module.exports: $Exports<'yjs/tests/y-map.tests'>;
}
declare module 'yjs/tests/y-text.tests.js' {
declare module.exports: $Exports<'yjs/tests/y-text.tests'>;
}
declare module 'yjs/tests/y-xml.tests.js' {
declare module.exports: $Exports<'yjs/tests/y-xml.tests'>;
}
+7 -2
View File
@@ -78,6 +78,7 @@
"@sentry/tracing": "^6.3.1",
"@tippy.js/react": "^2.2.2",
"@tommoor/remove-markdown": "^0.3.2",
"y-prosemirror": "^1.0.9",
"autotrack": "^2.4.1",
"aws-sdk": "^2.831.0",
"babel-plugin-lodash": "^3.3.4",
@@ -130,6 +131,7 @@
"koa-sendfile": "2.0.0",
"koa-sslify": "2.1.2",
"koa-static": "^4.0.1",
"lib0": "^0.2.34",
"lodash": "^4.17.19",
"mammoth": "^1.4.16",
"mobx": "^4.15.4",
@@ -189,7 +191,10 @@
"turndown": "^6.0.0",
"utf8": "^2.1.0",
"uuid": "^8.3.2",
"validator": "5.2.0"
"validator": "5.2.0",
"y-indexeddb": "^9.0.5",
"y-protocols": "^1.0.1",
"yjs": "^13.4.4"
},
"devDependencies": {
"@babel/cli": "^7.10.5",
@@ -231,4 +236,4 @@
"js-yaml": "^3.13.1"
},
"version": "0.56.0"
}
}
+8 -4
View File
@@ -3,7 +3,8 @@
exports[`#users.activate should activate a suspended user 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
@@ -55,7 +56,8 @@ Object {
exports[`#users.demote should demote an admin 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
@@ -175,7 +177,8 @@ Object {
exports[`#users.promote should promote a new admin 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
@@ -236,7 +239,8 @@ Object {
exports[`#users.suspend should suspend an user 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
+47 -53
View File
@@ -999,7 +999,6 @@ router.post("documents.update", auth(), async (ctx) => {
const editorVersion = ctx.headers["x-editor-version"];
ctx.assertPresent(id, "id is required");
ctx.assertPresent(title || text, "title or text is required");
if (append) ctx.assertPresent(text, "Text is required while appending");
const user = ctx.state.user;
@@ -1011,6 +1010,7 @@ router.post("documents.update", auth(), async (ctx) => {
}
const previousTitle = document.title;
const willPublish = publish && !document.published;
// Update document
if (title) document.title = title;
@@ -1025,67 +1025,61 @@ router.post("documents.update", auth(), async (ctx) => {
document.lastModifiedById = user.id;
const { collection } = document;
let transaction;
try {
transaction = await sequelize.transaction();
if (document.changed() || willPublish) {
let transaction;
try {
transaction = await sequelize.transaction();
if (publish) {
await document.publish({ transaction });
} else {
await document.save({ autosave, transaction });
}
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
}
throw err;
}
if (publish) {
await document.publish(user.id, { transaction });
} else {
await document.save({ autosave, transaction });
await Event.create({
name: "documents.update",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: {
autosave,
done,
title: document.title,
},
ip: ctx.request.ip,
});
}
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
if (document.title !== previousTitle) {
Event.add({
name: "documents.title_change",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: {
previousTitle,
title: document.title,
},
ip: ctx.request.ip,
});
}
throw err;
}
if (publish) {
await Event.create({
name: "documents.publish",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
} else {
await Event.create({
name: "documents.update",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: {
autosave,
done,
title: document.title,
},
ip: ctx.request.ip,
});
document.updatedBy = user;
document.collection = collection;
}
if (document.title !== previousTitle) {
Event.add({
name: "documents.title_change",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: {
previousTitle,
title: document.title,
},
ip: ctx.request.ip,
});
}
document.updatedBy = user;
document.collection = collection;
ctx.body = {
data: await presentDocument(document),
policies: presentPolicies(user, [document]),
+5
View File
@@ -17,6 +17,7 @@ router.post("team.update", auth(), async (ctx) => {
sharing,
guestSignin,
documentEmbeds,
multiplayerEditor,
} = ctx.body;
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
@@ -30,6 +31,10 @@ router.post("team.update", auth(), async (ctx) => {
if (sharing !== undefined) team.sharing = sharing;
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
if (guestSignin !== undefined) team.guestSignin = guestSignin;
if (multiplayerEditor !== undefined) {
team.multiplayerEditor = multiplayerEditor;
}
if (avatarUrl !== undefined) team.avatarUrl = avatarUrl;
const changes = team.changed();
+67
View File
@@ -0,0 +1,67 @@
// @flow
import { uniq } from "lodash";
import { schema, serializer } from "rich-markdown-editor";
import { yDocToProsemirror } from "y-prosemirror";
import * as Y from "yjs";
import { Document, Event } from "../models";
export default async function documentUpdater({
documentId,
ydoc,
userId,
done,
}: {
documentId: string,
ydoc: Y.Doc,
userId: string,
done?: boolean,
}) {
const document = await Document.findByPk(documentId);
const state = Y.encodeStateAsUpdate(ydoc);
const node = yDocToProsemirror(schema, ydoc);
const text = serializer.serialize(node);
// extract collaborators from doc user data
const pud = new Y.PermanentUserData(ydoc);
const pudIds = Array.from(pud.clients.values());
const existingIds = document.collaboratorIds;
const collaboratorIds = uniq([...pudIds, ...existingIds]);
if (document.text === text) {
return;
}
await Document.update(
{
text,
state: Buffer.from(state),
updatedAt: new Date(),
lastModifiedById: userId,
collaboratorIds,
},
{
hooks: false,
where: {
id: document.id,
},
}
);
const event = {
name: "documents.update",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: userId,
data: {
multiplayer: true,
title: document.title,
},
};
if (done) {
await Event.create(event);
} else {
await Event.add(event);
}
}
+168 -65
View File
@@ -1,17 +1,21 @@
// @flow
import http from "http";
import * as Sentry from "@sentry/node";
import debug from "debug";
import IO from "socket.io";
import socketRedisAdapter from "socket.io-redis";
import SocketAuth from "socketio-auth";
import app from "./app";
import { Document, Collection, View } from "./models";
import { Team, Document, Collection, View } from "./models";
import * as multiplayer from "./multiplayer";
import policy from "./policies";
import { client, subscriber } from "./redis";
import { getUserForJWT } from "./utils/jwt";
import { checkMigrations } from "./utils/startup";
const server = http.createServer(app.callback());
const log = debug("server");
let io;
const { can } = policy;
@@ -56,6 +60,7 @@ SocketAuth(io, {
}
},
postAuthenticate: async (socket, data) => {
log(`postAuthenticate ${socket.id}`);
const { user } = socket.client;
// the rooms associated with the current team
@@ -72,32 +77,116 @@ SocketAuth(io, {
// join all of the rooms at once
socket.join(rooms);
},
});
// allow the client to request to join rooms
socket.on("join", async (event) => {
// user is joining a collection channel, because their permissions have
// changed, granting them access.
if (event.collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
// receive multiplayer "sync" messages from other nodes (awareness and doc updates),
// applies data to doc if in memory otherwise the request is ignored
io.of("/").adapter.customHook = (event, callback) => {
io.of("/").clients((err, socketIds) => {
if (!socketIds.includes(event.socketId)) {
multiplayer.handleRemoteSync(
event.socketId,
event.documentId,
event.userId,
event.data
);
}
});
callback(true);
};
if (can(user, "read", collection)) {
socket.join(`collection-${event.collectionId}`);
io.on("connection", (socket) => {
socket.on("sync", (event) => {
if (!socket.auth) {
return;
}
const userId = socket.client.user.id;
// handleJoin must be called before handleSync to ensure authentication
// to communicate changes for the document/socket combo. Messages received
// before handleJoin will be logged and discarded.
multiplayer.handleSync(
socket,
event.documentId,
userId,
new Uint8Array(event.data)
);
// forward "sync" messages to all nodes (awareness and doc updates) so
// that any docs held in memory can be kept up to date.
// TODO: optimize by proactively keeping track of which nodes have doc in
// memory? Perf gains for large horizontal scaling.
io.of("/").adapter.customRequest(
{
socketId: socket.id,
documentId: event.documentId,
userId,
data: event.data,
},
(err) => {
if (err) {
log(err);
}
}
);
});
// user is joining a document channel, because they have navigated to
// view a document.
if (event.documentId) {
const document = await Document.findByPk(event.documentId, {
userId: user.id,
});
// allow the client to request to join rooms
socket.on("join", async (event) => {
if (!socket.auth) {
return;
}
if (can(user, "read", document)) {
const room = `document-${event.documentId}`;
log("join", event.documentId, socket.id);
const { user } = socket.client;
await View.touch(event.documentId, user.id, event.isEditing);
// user is joining a collection channel, because their permissions have
// changed, granting them access.
if (event.collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
if (can(user, "read", collection)) {
socket.join(`collection-${event.collectionId}`);
}
}
// user is joining a document channel, because they have navigated to
// view a document.
if (event.documentId) {
const team = await Team.findByPk(user.teamId);
const document = await Document.findByPk(event.documentId, {
userId: user.id,
});
if (can(user, "read", document)) {
const room = `document-${event.documentId}`;
// new logic for multiplayer editing completely changes "presence"
// detection and propagation, so split at a high-level here.
if (team.multiplayerEditor) {
log("joined multiplayer", socket.id);
socket.join(room, () => {
socket.emit("user.join", {
userId: user.id,
documentId: event.documentId,
});
multiplayer.handleJoin({
io,
document,
socket: socket,
documentId: event.documentId,
});
});
} else {
// old deprecated logic to be removed in the future once multiplayer
// has stabilized
await View.touch(event.documentId, user.id);
const editing = await View.findRecentlyEditingByDocument(
event.documentId
);
@@ -111,11 +200,11 @@ SocketAuth(io, {
});
// let this user know who else is already present in the room
io.in(room).clients(async (err, sockets) => {
io.in(room).clients(async (err, socketIds) => {
if (err) {
if (process.env.SENTRY_DSN) {
Sentry.withScope(function (scope) {
scope.setExtra("clients", sockets);
scope.setExtra("clients", socketIds);
Sentry.captureException(err);
});
} else {
@@ -128,7 +217,7 @@ SocketAuth(io, {
// need to make sure that only unique userIds are returned. A Map
// makes this easy.
let userIds = new Map();
for (const socketId of sockets) {
for (const socketId of socketIds) {
const userId = await client.hget(socketId, "userId");
userIds.set(userId, userId);
}
@@ -141,57 +230,71 @@ SocketAuth(io, {
});
}
}
});
}
});
// allow the client to request to leave rooms
socket.on("leave", (event) => {
if (event.collectionId) {
socket.leave(`collection-${event.collectionId}`);
}
if (event.documentId) {
const room = `document-${event.documentId}`;
socket.leave(room, () => {
io.to(room).emit("user.leave", {
userId: user.id,
documentId: event.documentId,
});
});
}
});
// allow the client to request to leave rooms
socket.on("leave", (event) => {
if (!socket.auth) {
return;
}
socket.on("disconnecting", () => {
const rooms = Object.keys(socket.rooms);
if (event.collectionId) {
socket.leave(`collection-${event.collectionId}`);
}
rooms.forEach((room) => {
if (room.startsWith("document-")) {
const documentId = room.replace("document-", "");
io.to(room).emit("user.leave", {
userId: user.id,
documentId,
});
}
});
});
socket.on("presence", async (event) => {
if (event.documentId) {
const room = `document-${event.documentId}`;
const userId = socket.client.user.id;
if (event.documentId && socket.rooms[room]) {
const view = await View.touch(
event.documentId,
user.id,
event.isEditing
);
view.user = user;
io.to(room).emit("user.presence", {
userId: user.id,
socket.leave(room, () => {
io.to(room).emit("user.leave", {
userId,
documentId: event.documentId,
isEditing: event.isEditing,
});
});
multiplayer.handleLeave(socket.id, userId, event.documentId);
}
});
socket.on("disconnecting", () => {
if (!socket.auth) {
return;
}
const rooms = Object.keys(socket.rooms);
rooms.forEach((room) => {
if (room.startsWith("document-")) {
const documentId = room.replace("document-", "");
const userId = socket.client.user.id;
io.to(room).emit("user.leave", {
userId,
documentId,
});
multiplayer.handleLeave(socket.id, userId, documentId);
}
});
},
});
socket.on("presence", async (event) => {
if (!socket.auth) {
return;
}
const room = `document-${event.documentId}`;
const { user } = socket.client;
if (event.documentId && socket.rooms[room]) {
const view = await View.touch(event.documentId, user.id, event.isEditing);
view.user = user;
io.to(room).emit("user.presence", {
userId: user.id,
documentId: event.documentId,
isEditing: event.isEditing,
});
}
});
});
server.on("error", (err) => {
@@ -0,0 +1,18 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('documents', 'state', {
type: Sequelize.BLOB
});
await queryInterface.addColumn('teams', 'multiplayerEditor', {
type: Sequelize.BOOLEAN,
defaultValue: false,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('documents', 'state');
await queryInterface.removeColumn('teams', 'multiplayerEditor');
}
};
+1
View File
@@ -74,6 +74,7 @@ const Document = sequelize.define(
template: DataTypes.BOOLEAN,
editorVersion: DataTypes.STRING,
text: DataTypes.TEXT,
state: DataTypes.BLOB,
// backup contains a record of text at the moment it was converted to v2
// this is a safety measure during deployment of new editor and will be
+5
View File
@@ -69,6 +69,11 @@ const Team = sequelize.define(
allowNull: false,
defaultValue: true,
},
multiplayerEditor: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
},
{
paranoid: true,
+6
View File
@@ -7,6 +7,7 @@ import { languages } from "../../shared/i18n";
import { ValidationError } from "../errors";
import { DataTypes, sequelize, encryptedFields, Op } from "../sequelize";
import { DEFAULT_AVATAR_HOST } from "../utils/avatars";
import { palette } from "../utils/color";
import { publicS3Endpoint, uploadToS3FromUrl } from "../utils/s3";
import {
UserAuthentication,
@@ -74,6 +75,11 @@ const User = sequelize.define(
.digest("hex");
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png`;
},
color() {
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
const idAsNumber = parseInt(idAsHex, 16);
return palette[idAsNumber % palette.length];
},
},
}
);
+96
View File
@@ -0,0 +1,96 @@
// @flow
import * as encoding from "lib0/dist/encoding.cjs";
import * as mutex from "lib0/dist/mutex.cjs";
import { parser } from "rich-markdown-editor";
import { prosemirrorToYDoc } from "y-prosemirror";
import * as awarenessProtocol from "y-protocols/dist/awareness.cjs";
import * as syncProtocol from "y-protocols/dist/sync.cjs";
import * as Y from "yjs";
import { MESSAGE_AWARENESS, MESSAGE_SYNC } from "../../shared/constants";
import { Document } from "../models";
export default class WSSharedDoc extends Y.Doc {
constructor(document: Document, io: any) {
super({ gc: true });
this.io = io;
this.documentId = document.id;
this.mux = mutex.createMutex();
this.conns = new Map();
this.awareness = new awarenessProtocol.Awareness(this);
this.awareness.setLocalState(null);
if (document.state) {
Y.applyUpdate(this, document.state);
} else {
const node = parser.parse(document.text);
const ydoc = prosemirrorToYDoc(node);
Y.applyUpdate(this, Y.encodeStateAsUpdate(ydoc));
}
this.awareness.on("update", this.awarenessHandler);
this.on("update", this.updateHandler);
}
destroy() {
this.off("update", this.updateHandler);
this.awareness.off("update", this.awarenessHandler);
this.awareness.destroy();
super.destroy();
}
awarenessHandler = (
{
added,
updated,
removed,
}: { added: Array<number>, updated: Array<number>, removed: Array<number> },
socketId: number
) => {
const changedClients = added.concat(updated, removed);
if (socketId !== null) {
const connControlledIDs = this.conns.get(socketId);
if (connControlledIDs !== undefined) {
added.forEach((clientID) => {
connControlledIDs.add(clientID);
});
removed.forEach((clientID) => {
connControlledIDs.delete(clientID);
});
}
}
// broadcast awareness update
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients)
);
const data = encoding.toUint8Array(encoder);
this.io
.to(`document-${this.documentId}`)
.binary(true)
.emit("document.sync", {
documentId: this.documentId,
data,
});
};
updateHandler = (update: Uint8Array, origin: any) => {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.writeUpdate(encoder, update);
const data = encoding.toUint8Array(encoder);
this.io
.to(`document-${this.documentId}`)
.binary(true)
.emit("document.sync", {
documentId: this.documentId,
data,
});
};
}
+242
View File
@@ -0,0 +1,242 @@
// @flow
import debug from "debug";
import * as decoding from "lib0/dist/decoding.cjs";
import * as encoding from "lib0/dist/encoding.cjs";
import { debounce } from "lodash";
import { Socket } from "socket.io-client";
import * as awarenessProtocol from "y-protocols/dist/awareness.cjs";
import * as syncProtocol from "y-protocols/dist/sync.cjs";
import * as Y from "yjs";
import { MESSAGE_AWARENESS, MESSAGE_SYNC } from "../../shared/constants";
import documentUpdater from "../commands/documentUpdater";
import { Document } from "../models";
import WSSharedDoc from "./WSSharedDoc";
const log = debug("multiplayer");
const docs = new Map<string, WSSharedDoc>();
const PERSIST_WAIT = 3000;
export function handleJoin({
io,
socket,
document,
documentId,
}: {
io: any,
socket: Socket,
document: Document,
documentId: string,
}) {
log(`socket ${socket.id} is joining ${documentId}`);
let doc = docs.get(documentId);
if (!doc) {
doc = new WSSharedDoc(document, io);
doc.get("prosemirror", Y.XmlFragment);
if (document.state) {
log(`no existing session for ${documentId} using database state`);
Y.applyUpdate(doc, document.state);
} else {
log(`no existing session for ${documentId} no database state`);
}
doc.on(
"update",
debounce(
async (update, origin: { userId: string, remote?: boolean }) => {
// If the origin is "remote" this means that the transaction came from
// a remote server process, as we're just accepting transactions to
// keep us in sync with another doc there is no need to persist.
if (origin.remote) {
return;
}
log(`persisting doc (${documentId}) to database`);
await documentUpdater({
documentId,
ydoc: doc,
userId: origin.userId,
});
},
PERSIST_WAIT,
{
maxWait: PERSIST_WAIT * 3,
}
)
);
docs.set(documentId, doc);
}
doc.conns.set(socket.id, new Set());
// send sync step 1
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.writeSyncStep1(encoder, doc);
socket.binary(true).emit("document.sync", {
documentId,
data: encoding.toUint8Array(encoder),
});
const awarenessStates = doc.awareness.getStates();
if (awarenessStates.size > 0) {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(
doc.awareness,
Array.from(awarenessStates.keys())
)
);
socket.binary(true).emit("document.sync", {
documentId,
data: encoding.toUint8Array(encoder),
});
}
}
export async function handleLeave(
socketId: string,
userId: string,
documentId: string
) {
let doc = docs.get(documentId);
// this method is called for all leave events, even old-style, so it needs
// to handle attempting to leave when there is no existing connection
if (!doc || !doc.conns.has(socketId)) {
return;
}
// remove ourselves from the awareness state
const controlledIds = doc.conns.get(socketId);
doc.conns.delete(socketId);
awarenessProtocol.removeAwarenessStates(
doc.awareness,
Array.from(controlledIds),
null
);
// last client has left this document connection, time to cleanup and ensure
// we've written the latest state to the database.
// Important note: In multi-server setups this can mean that everyone has left
// on an individual server process, however their may still be other clients
// connected to other processes
// TODO: store connections in redis?
if (doc.conns.size === 0) {
log(`all clients left doc (${documentId}), persisting…`);
await documentUpdater({ documentId, ydoc: doc, userId, done: true });
doc.destroy();
docs.delete(documentId);
}
}
export function handleSync(
socket: Socket,
documentId: string,
userId: string,
message: Uint8Array
) {
// check auth with existence of socketId in set
let doc = docs.get(documentId);
if (!doc) {
log(`received sync message but doc (${documentId}) not yet loaded`);
return;
}
if (!doc.conns.get(socket.id)) {
log(
`received sync message but socket (${socket.id}) has not joined doc (${documentId})`
);
return;
}
const encoder = encoding.createEncoder();
const decoder = decoding.createDecoder(message);
const messageType = decoding.readVarUint(decoder);
switch (messageType) {
case MESSAGE_SYNC: {
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.readSyncMessage(decoder, encoder, doc, { userId });
if (encoding.length(encoder) > 1) {
socket.binary(true).emit("document.sync", {
documentId,
data: encoding.toUint8Array(encoder),
});
}
break;
}
case MESSAGE_AWARENESS: {
awarenessProtocol.applyAwarenessUpdate(
doc.awareness,
decoding.readVarUint8Array(decoder),
socket.id
);
break;
}
default:
}
}
export function handleRemoteSync(
socketId: string,
documentId: string,
userId: string,
data: {
type: string,
data: ArrayBuffer,
}
) {
let doc = docs.get(documentId);
if (!doc) {
if (process.env.NODE_ENV === "development") {
log(`received remote sync message but doc (${documentId}) not loaded`);
}
return;
}
if (!doc.conns.get(socketId)) {
if (process.env.NODE_ENV === "development") {
log(
`received remote sync message but socket (${socketId}) has not joined doc (${documentId})`
);
}
return;
}
// Note: This is different to handleSync parsing moved to here so that we
// can avoid conversion steps if the doc doesn't already exist in memory.
const message = new Uint8Array(Buffer.from(data.data));
const encoder = encoding.createEncoder();
const decoder = decoding.createDecoder(message);
const messageType = decoding.readVarUint(decoder);
switch (messageType) {
case MESSAGE_SYNC: {
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.readSyncMessage(decoder, encoder, doc, {
userId,
remote: true,
});
break;
}
case MESSAGE_AWARENESS: {
awarenessProtocol.applyAwarenessUpdate(
doc.awareness,
decoding.readVarUint8Array(decoder),
socketId
);
break;
}
default:
}
}
@@ -3,6 +3,7 @@
exports[`presents a user 1`] = `
Object {
"avatarUrl": undefined,
"color": undefined,
"createdAt": undefined,
"id": "123",
"isAdmin": undefined,
@@ -16,6 +17,7 @@ Object {
exports[`presents a user without slack data 1`] = `
Object {
"avatarUrl": undefined,
"color": undefined,
"createdAt": undefined,
"id": "123",
"isAdmin": undefined,
+1
View File
@@ -9,6 +9,7 @@ export default function present(team: Team) {
sharing: team.sharing,
documentEmbeds: team.documentEmbeds,
guestSignin: team.guestSignin,
multiplayerEditor: team.multiplayerEditor,
subdomain: team.subdomain,
domain: team.domain,
url: team.url,
+2
View File
@@ -10,6 +10,7 @@ type UserPresentation = {
name: string,
avatarUrl: ?string,
email?: string,
color: string,
isAdmin: boolean,
isSuspended: boolean,
isViewer: boolean,
@@ -25,6 +26,7 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
userData.isViewer = user.isViewer;
userData.isSuspended = user.isSuspended;
userData.avatarUrl = user.avatarUrl;
userData.color = user.color;
userData.lastActiveAt = user.lastActiveAt;
if (options.includeDetails) {
+12 -4
View File
@@ -1,6 +1,6 @@
// @flow
import type { DocumentEvent, RevisionEvent } from "../events";
import { Document, Backlink } from "../models";
import { Document, Team, Backlink } from "../models";
import { Op } from "../sequelize";
import parseDocumentIds from "../utils/parseDocumentIds";
import slugify from "../utils/slugify";
@@ -78,12 +78,20 @@ export default class Backlinks {
break;
}
case "documents.title_change": {
// might as well check
const { title, previousTitle } = event.data;
if (!previousTitle || title === previousTitle) {
break;
}
const document = await Document.findByPk(event.documentId);
if (!document) return;
// might as well check
const { title, previousTitle } = event.data;
if (!previousTitle || title === previousTitle) break;
// TODO: Handle re-writing of titles into CRDT
const team = await Team.findByPk(document.teamId);
if (team.multiplayerEditor) {
break;
}
// update any link titles in documents that lead to this one
const backlinks = await Backlink.findAll({
+20
View File
@@ -0,0 +1,20 @@
// @flow
import { darken } from "polished";
import theme from "../../shared/styles/theme";
export const palette = [
theme.brand.red,
theme.brand.blue,
theme.brand.purple,
theme.brand.pink,
theme.brand.marine,
theme.brand.green,
theme.brand.yellow,
darken(0.2, theme.brand.red),
darken(0.2, theme.brand.blue),
darken(0.2, theme.brand.purple),
darken(0.2, theme.brand.pink),
darken(0.2, theme.brand.marine),
darken(0.2, theme.brand.green),
darken(0.2, theme.brand.yellow),
];
+4
View File
@@ -3,3 +3,7 @@
export const USER_PRESENCE_INTERVAL = 5000;
export const MAX_AVATAR_DISPLAY = 6;
export const MAX_TITLE_LENGTH = 100;
export const MESSAGE_SYNC = 0;
export const MESSAGE_AWARENESS = 1;
export const MESSAGE_QUERY_AWARENESS = 3;
+5
View File
@@ -6,6 +6,11 @@ export const fadeIn = keyframes`
to { opacity: 1; }
`;
export const fadeOut = keyframes`
from { opacity: 0; }
to { opacity: 1; }
`;
export const fadeAndScaleIn = keyframes`
from {
opacity: 0;
+1
View File
@@ -40,6 +40,7 @@ const colors = {
blue: "#3633FF",
marine: "#2BC2FF",
green: "#42DED1",
yellow: "#F5BE31",
},
};
+41 -1
View File
@@ -1009,7 +1009,7 @@
debug "^4.1.0"
globals "^11.1.0"
"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.13", "@babel/types@^7.12.5", "@babel/types@^7.14.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.5", "@babel/types@^7.14.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.14.5.tgz#3bb997ba829a2104cedb20689c4a5b8121d383ff"
integrity sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg==
@@ -7302,6 +7302,11 @@ isomorphic-fetch@^3.0.0:
node-fetch "^2.6.1"
whatwg-fetch "^3.4.1"
isomorphic.js@^0.1.3:
version "0.1.5"
resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.1.5.tgz#f210e58ba4c96978a991c443cf67283366e9b70f"
integrity sha512-MkX5lLQApx/8IAIU31PKvpAZosnu2Jqcj1rM8TzxyA4CR96tv3SgMKQNTCxL58G7696Q57zd7ubHV/hTg+5fNA==
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@@ -8258,6 +8263,13 @@ levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"
lib0@^0.2.12, lib0@^0.2.28, lib0@^0.2.33, lib0@^0.2.34:
version "0.2.34"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.34.tgz#c4479f5f2083894687fcfa9d0b9d9935e35ea008"
integrity sha512-cqsVIMPgFlDtgQcpkt7HOY6W3sbYPIe3qxMnbRSwHTgiQancgm+TRDPx28mC6GUZ6lG6Nr0bIWf4Nog6dWUNUg==
dependencies:
isomorphic.js "^0.1.3"
lie@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
@@ -13994,6 +14006,27 @@ xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y-indexeddb@^9.0.5:
version "9.0.5"
resolved "https://registry.yarnpkg.com/y-indexeddb/-/y-indexeddb-9.0.5.tgz#a162526add738a456b7185a6a5626534609ee132"
integrity sha512-40VxkqPoK2VxE1vMosS5MfwlHQOvaeLEN89dIkjh7URjZny6bDQOl4yKldaDv9ZosZgYEPyWuWTF3Z92RZ1y+A==
dependencies:
lib0 "^0.2.12"
y-prosemirror@^1.0.9:
version "1.0.9"
resolved "https://registry.yarnpkg.com/y-prosemirror/-/y-prosemirror-1.0.9.tgz#c0b5bf4e2c6620093ba0658c2aca52055346a683"
integrity sha512-OM12aPx04lwiIy1IOBidb6ONAof2KFxQE/Gww26SEsMQuA2dibrJkjaMwXwY1KnYY7yOpwbIFRdwecdNXLU9yQ==
dependencies:
lib0 "^0.2.34"
y-protocols@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.1.tgz#7855c900039a02b369590b8ae78bc6e1cbc13c9f"
integrity sha512-QP3fCM7c2gGfUi2nqf8gspyO4VW23zv3kNqPNdD3wNxMbuNQenMyoDVZYEo12jzR4RQ3aaDfPK62Sf31SVOmfg==
dependencies:
lib0 "^0.2.28"
y18n@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
@@ -14081,6 +14114,13 @@ yeast@0.1.2:
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
yjs@^13.4.4:
version "13.4.4"
resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.4.4.tgz#3a8b90abf38b14c1d352d9d98098541cf7187d39"
integrity sha512-5YUsjK28pzNu2MOvvc+KTSvlnjhNNPiZF828ac0HuPg2rfaex/IBOdZE3bgZ/UEfIUdDPrKNFpXKaP6Z9MzJ1w==
dependencies:
lib0 "^0.2.33"
ylru@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"