Compare commits

...

43 Commits

Author SHA1 Message Date
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
36 changed files with 2544 additions and 176 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/@tommoor/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
+61 -9
View File
@@ -97,17 +97,55 @@ const StyledEditor = styled(RichMarkdownEditor)`
font-weight: 500;
}
.heading-name {
pointer-events: none;
.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;
}
}
}
}
/* pseudo element allows us to add spacing for fixed header */
/* ref: https://stackoverflow.com/a/28824157 */
.heading-name::before {
content: "";
display: ${(props) => (props.readOnly ? "block" : "none")};
height: 72px;
margin: -72px 0 0;
.heading-name {
pointer-events: none;
}
.heading-name:first-child {
@@ -149,3 +187,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;
// }
+1
View File
@@ -10,6 +10,7 @@ class Team extends BaseModel {
googleConnected: boolean;
sharing: boolean;
documentEmbeds: boolean;
multiplayerEditor: boolean;
guestSignin: boolean;
subdomain: ?string;
domain: ?string;
+1
View File
@@ -7,6 +7,7 @@ class User extends BaseModel {
id: string;
name: string;
email: string;
color: string;
isAdmin: boolean;
lastActiveAt: string;
isSuspended: boolean;
+67
View File
@@ -0,0 +1,67 @@
// @flow
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "@tommoor/y-prosemirror";
import { keymap } from "prosemirror-keymap";
import { Extension } from "rich-markdown-editor";
// import { IndexeddbPersistence } from "y-indexeddb";
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);
provider.on("status", ({ status }) => {
if (status === "connected") {
provider.awareness.setLocalStateField("user", {
color: user.color,
name: user.name,
id: user.id,
});
}
});
provider.once("sync", () => {
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);
}
};
doc.on("afterTransaction", assignUser);
});
// const dbProvider = new IndexeddbPersistence(doc.documentId, doc);
// dbProvider.whenSynced.then(() => {
// console.log("loaded data from indexed db");
// });
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();
}
}
}
+2
View File
@@ -1,6 +1,7 @@
// @flow
import * as React from "react";
import { Switch, Route } from "react-router-dom";
import Features from "scenes/Settings/Features";
import Settings from "scenes/Settings";
import Details from "scenes/Settings/Details";
import Export from "scenes/Settings/Export";
@@ -18,6 +19,7 @@ export default function SettingsRoutes() {
<Switch>
<Route exact path="/settings" component={Settings} />
<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/people" component={People} />
<Route exact path="/settings/people/:filter" component={People} />
+33 -15
View File
@@ -9,6 +9,7 @@ import type { RouterHistory, Match } from "react-router-dom";
import { withRouter } from "react-router-dom";
import { withTheme } from "styled-components";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import RevisionsStore from "stores/RevisionsStore";
@@ -30,6 +31,7 @@ import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
type Props = {|
match: Match,
location: LocationWithState,
auth: AuthStore,
shares: SharesStore,
documents: DocumentsStore,
policies: PoliciesStore,
@@ -217,7 +219,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 ? (
@@ -227,10 +229,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} />
@@ -240,21 +243,36 @@ class DataLoader extends React.Component<Props> {
}
const abilities = policies.abilities(document.id);
const key = this.isEditing ? "editing" : "read-only";
const key = team.multiplayerEditor
? ""
: this.isEditing
? "editing"
: "read-only";
return (
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
{this.isEditing && <HideSidebar ui={ui} />}
<DocumentComponent
key={key}
document={document}
revision={revision}
abilities={abilities}
location={location}
readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
/>
<SocketPresence
documentId={document.id}
userId={auth.user ? auth.user.id : undefined}
isMultiplayer={team.multiplayerEditor}
>
{(multiplayer) => (
<>
{this.isEditing && <HideSidebar ui={ui} />}
<DocumentComponent
key={key}
document={document}
revision={revision}
abilities={abilities}
location={location}
multiplayer={multiplayer}
readOnly={
!this.isEditing || !abilities.update || document.isArchived
}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
/>
</>
)}
</SocketPresence>
);
}
+47 -8
View File
@@ -9,7 +9,7 @@ import { Prompt, Route, withRouter } from "react-router-dom";
import type { RouterHistory, Match } 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";
@@ -30,6 +30,8 @@ import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import MarkAsViewed from "./MarkAsViewed";
import References from "./References";
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
import { type LocationWithState, type Theme } from "types";
import { isCustomDomain } from "utils/domains";
import { emojiToUrl } from "utils/emoji";
@@ -60,6 +62,12 @@ type Props = {
document: Document,
revision: Revision,
readOnly: boolean,
multiplayer: {
isConnected: boolean,
isReconnecting: boolean,
provider: ?WebsocketProvider,
doc: Y.Doc,
},
onCreateLink: (title: string) => string,
onSearchLink: (term: string) => any,
theme: Theme,
@@ -195,7 +203,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;
@@ -223,10 +231,14 @@ 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) {
savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
}
this.isDirty = false;
this.lastRevision = savedDocument.revision;
@@ -311,8 +323,10 @@ class DocumentScene extends React.Component<Props> {
auth,
ui,
match,
multiplayer,
} = this.props;
const team = auth.team;
const user = auth.user;
const isShare = !!match.params.shareId;
const value = revision ? revision.text : document.text;
@@ -350,7 +364,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,6 +428,16 @@ 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…"}
</Notice>
)}
<React.Suspense fallback={<LoadingPlaceholder />}>
<Flex auto={!readOnly}>
{showContents && <Contents headings={headings} />}
@@ -422,7 +451,7 @@ class DocumentScene extends React.Component<Props> {
title={revision ? revision.title : this.title}
document={document}
value={readOnly ? value : undefined}
defaultValue={value}
defaultValue={team && team.multiplayerEditor ? "" : value}
disableEmbeds={disableEmbeds}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
@@ -436,6 +465,16 @@ class DocumentScene extends React.Component<Props> {
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
ui={this.props.ui}
extensions={
team && team.multiplayerEditor && !isShare && !revision
? [
new MultiplayerExtension({
user,
...multiplayer,
}),
]
: undefined
}
/>
</Flex>
{readOnly && !isShare && !revision && (
+10 -10
View File
@@ -11,7 +11,7 @@ import {
} from "outline-icons";
import { transparentize, darken } from "polished";
import * as React from "react";
import { Redirect } from "react-router-dom";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore";
@@ -50,6 +50,7 @@ type Props = {
publishingIsDisabled: boolean,
savingIsDisabled: boolean,
onDiscard: () => void,
history: RouterHistory,
onSave: ({
done?: boolean,
publish?: boolean,
@@ -61,7 +62,6 @@ type Props = {
class Header extends React.Component<Props> {
@observable isScrolled = false;
@observable showShareModal = false;
@observable redirectTo: ?string;
componentDidMount() {
window.addEventListener("scroll", this.handleScroll);
@@ -78,15 +78,17 @@ class Header extends React.Component<Props> {
handleScroll = throttle(this.updateIsScrolled, 50);
handleEdit = () => {
this.redirectTo = editDocumentUrl(this.props.document);
this.props.history.push(editDocumentUrl(this.props.document));
};
handleNewFromTemplate = () => {
const { document } = this.props;
const { document, history } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, {
templateId: document.id,
});
history.push(
newDocumentUrl(document.collectionId, {
templateId: document.id,
})
);
};
handleSave = () => {
@@ -116,8 +118,6 @@ class Header extends React.Component<Props> {
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const {
shares,
document,
@@ -425,4 +425,4 @@ const Title = styled.div`
`};
`;
export default inject("auth", "ui", "policies", "shares")(Header);
export default inject("auth", "ui", "policies", "shares")(withRouter(Header));
@@ -1,77 +1,106 @@
// @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 [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();
React.useEffect(() => {
return () => {
console.log("destroy");
provider.destroy();
};
}, []);
const awareness = provider && provider.awareness;
React.useEffect(() => {
const onUpdate = () => {
presence.updateFromAwareness(props.documentId, awareness);
};
if (awareness) {
awareness.on("update", onUpdate);
}
return () => {
if (awareness) {
awareness.off("update", onUpdate);
}
}, USER_PRESENCE_INTERVAL);
this.setupOnce();
}
};
}, [presence, props.documentId, awareness]);
componentDidUpdate(prevProps: Props) {
this.setupOnce();
React.useEffect(() => {
console.log("useEffect", context);
if (!context) return;
if (prevProps.isEditing !== this.props.isEditing) {
this.emitPresence();
}
}
const emitJoin = () => {
if (!context) return;
context.emit("join", { documentId: props.documentId });
};
componentWillUnmount() {
if (this.context) {
this.context.emit("leave", { documentId: this.props.documentId });
this.context.off("authenticated", this.emitJoin);
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();
}
clearInterval(this.editingInterval);
}
return () => {
if (!context) return;
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]);
setupOnce = () => {
if (this.context && this.context !== this.previousContext) {
this.previousContext = this.context;
if (this.context.authenticated) {
this.emitJoin();
}
this.context.on("authenticated", () => {
this.emitJoin();
});
}
};
emitJoin = () => {
if (!this.context) return;
this.context.emit("join", {
documentId: this.props.documentId,
isEditing: this.props.isEditing,
});
};
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,
isReconnecting,
provider,
doc,
});
}
+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
+74
View File
@@ -0,0 +1,74 @@
// 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:
*
* '@tommoor/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 "@tommoor/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 "@tommoor/y-prosemirror/dist/test" {
declare module.exports: any;
}
declare module "@tommoor/y-prosemirror/src/lib" {
declare module.exports: any;
}
declare module "@tommoor/y-prosemirror/src/plugins/cursor-plugin" {
declare module.exports: any;
}
declare module "@tommoor/y-prosemirror/src/plugins/sync-plugin" {
declare module.exports: any;
}
declare module "@tommoor/y-prosemirror/src/plugins/undo-plugin" {
declare module.exports: any;
}
declare module "@tommoor/y-prosemirror/src/y-prosemirror" {
declare module.exports: any;
}
// Filename aliases
declare module "@tommoor/y-prosemirror/dist/test.js" {
declare module.exports: $Exports<"@tommoor/y-prosemirror/dist/test">;
}
declare module "@tommoor/y-prosemirror/src/lib.js" {
declare module.exports: $Exports<"@tommoor/y-prosemirror/src/lib">;
}
declare module "@tommoor/y-prosemirror/src/plugins/cursor-plugin.js" {
declare module.exports: $Exports<
"@tommoor/y-prosemirror/src/plugins/cursor-plugin"
>;
}
declare module "@tommoor/y-prosemirror/src/plugins/sync-plugin.js" {
declare module.exports: $Exports<
"@tommoor/y-prosemirror/src/plugins/sync-plugin"
>;
}
declare module "@tommoor/y-prosemirror/src/plugins/undo-plugin.js" {
declare module.exports: $Exports<
"@tommoor/y-prosemirror/src/plugins/undo-plugin"
>;
}
declare module "@tommoor/y-prosemirror/src/y-prosemirror.js" {
declare module.exports: $Exports<"@tommoor/y-prosemirror/src/y-prosemirror">;
}
+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'>;
}
+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'>;
}
+8 -3
View File
@@ -9,7 +9,7 @@
"build:webpack": "webpack --config webpack.config.prod.js",
"build": "yarn clean && yarn build:webpack && yarn build:server",
"start": "node ./build/server/index.js",
"dev": "nodemon --exec \"yarn build:server && node build/server/index.js\" -e js --ignore build/ --ignore app/",
"dev": "nodemon --exec \"yarn build:server && node --inspect build/server/index.js\" -e js --ignore build/ --ignore app/",
"lint": "eslint app server shared",
"flow": "flow",
"deploy": "git push heroku master",
@@ -69,6 +69,7 @@
"@sentry/node": "^5.23.0",
"@tippy.js/react": "^2.2.2",
"@tommoor/remove-markdown": "0.3.1",
"@tommoor/y-prosemirror": "^0.4.1",
"autotrack": "^2.4.1",
"aws-sdk": "^2.135.0",
"babel-plugin-lodash": "^3.3.4",
@@ -115,6 +116,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.11",
"mobx": "4.6.0",
@@ -164,7 +166,10 @@
"turndown": "^6.0.0",
"utf8": "^2.1.0",
"uuid": "2.0.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",
@@ -200,4 +205,4 @@
"js-yaml": "^3.13.1"
},
"version": "0.50.0"
}
}
+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 { yDocToProsemirror } from "@tommoor/y-prosemirror";
import { uniq } from "lodash";
import { schema, serializer } from "rich-markdown-editor";
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);
}
}
+167 -64
View File
@@ -1,15 +1,19 @@
// @flow
import http from "http";
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";
const server = http.createServer(app.callback());
const log = debug("server");
let io;
const { can } = policy;
@@ -54,6 +58,7 @@ SocketAuth(io, {
}
},
postAuthenticate: async (socket, data) => {
log(`postAuthenticate ${socket.id}`);
const { user } = socket.client;
// the rooms associated with the current team
@@ -70,32 +75,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
);
@@ -109,14 +198,14 @@ 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) throw err;
// because a single user can have multiple socket connections we
// 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);
}
@@ -129,57 +218,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');
}
};
+4
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
@@ -95,6 +96,9 @@ const Document = sequelize.define(
},
getterMethods: {
url: function () {
if (!this.title) {
return;
}
const slugifiedTitle = slugify(this.title);
return `/doc/${slugifiedTitle}-${this.urlId}`;
},
+5
View File
@@ -66,6 +66,11 @@ const Team = sequelize.define(
allowNull: false,
defaultValue: true,
},
multiplayerEditor: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
slackData: DataTypes.JSONB,
},
{
+9 -1
View File
@@ -7,6 +7,7 @@ import uuid from "uuid";
import { ValidationError } from "../errors";
import { sendEmail } from "../mailer";
import { DataTypes, sequelize, encryptedFields } from "../sequelize";
import { palette } from "../utils/color";
import { publicS3Endpoint, uploadToS3FromUrl } from "../utils/s3";
import { Star, Team, Collection, NotificationSetting, ApiKey } from ".";
@@ -53,7 +54,14 @@ const User = sequelize.define(
.createHash("md5")
.update(this.email || "")
.digest("hex");
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${this.name[0]}.png`;
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${
this.name[0]
}.png?c=${this.color.replace("#", "")}`;
},
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 { prosemirrorToYDoc } from "@tommoor/y-prosemirror";
import * as encoding from "lib0/dist/encoding.cjs";
import * as mutex from "lib0/dist/mutex.cjs";
import { parser } from "rich-markdown-editor";
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,
});
};
}
+228
View File
@@ -0,0 +1,228 @@
// @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, userId) => {
log(`persisting doc (${documentId}) to database`);
await documentUpdater({ documentId, ydoc: doc, 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);
break;
}
case MESSAGE_AWARENESS: {
awarenessProtocol.applyAwarenessUpdate(
doc.awareness,
decoding.readVarUint8Array(decoder),
socketId
);
break;
}
default:
}
}
+1
View File
@@ -11,6 +11,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,
};
@@ -23,6 +24,7 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
userData.isAdmin = user.isAdmin;
userData.isSuspended = user.isSuspended;
userData.avatarUrl = user.avatarUrl;
userData.color = user.color;
if (options.includeDetails) {
userData.email = user.email;
+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";
@@ -76,12 +76,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
@@ -39,6 +39,7 @@ const colors = {
blue: "#3633FF",
marine: "#2BC2FF",
green: "#42DED1",
yellow: "#F5BE31",
},
};
+138 -2
View File
@@ -1372,6 +1372,40 @@
execa "^4.0.0"
java-properties "^1.0.0"
"@rollup/plugin-commonjs@^15.1.0":
version "15.1.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-15.1.0.tgz#1e7d076c4f1b2abf7e65248570e555defc37c238"
integrity sha512-xCQqz4z/o0h2syQ7d9LskIMvBSH4PX5PjYdpSSvgS+pQik3WahkQVNWg3D8XJeYjZoVWnIUQYDghuEMRGrmQYQ==
dependencies:
"@rollup/pluginutils" "^3.1.0"
commondir "^1.0.1"
estree-walker "^2.0.1"
glob "^7.1.6"
is-reference "^1.2.1"
magic-string "^0.25.7"
resolve "^1.17.0"
"@rollup/plugin-node-resolve@^9.0.0":
version "9.0.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-9.0.0.tgz#39bd0034ce9126b39c1699695f440b4b7d2b62e6"
integrity sha512-gPz+utFHLRrd41WMP13Jq5mqqzHL3OXrfj3/MkSyB6UBIcuNt9j60GCbarzMzdf1VHFpOxfQh/ez7wyadLMqkg==
dependencies:
"@rollup/pluginutils" "^3.1.0"
"@types/resolve" "1.17.1"
builtin-modules "^3.1.0"
deepmerge "^4.2.2"
is-module "^1.0.0"
resolve "^1.17.0"
"@rollup/pluginutils@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
dependencies:
"@types/estree" "0.0.39"
estree-walker "^1.0.1"
picomatch "^2.2.2"
"@sentry/core@5.23.0":
version "5.23.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.23.0.tgz#4d12ee4f5593e66fa5ffde0f9e9164a5468e5cec"
@@ -1472,6 +1506,17 @@
resolved "https://registry.yarnpkg.com/@tommoor/remove-markdown/-/remove-markdown-0.3.1.tgz#25e7b845d52fcfadf149a3a6a468a931fee7619b"
integrity sha512-aM5TtBfBgcUm+B4WWelm2NBAFBk12oNUr67f5lJapSOTkPnwkuzCNwMlsBoDTsRknoZSsUIkcOJB473AnfyqHA==
"@tommoor/y-prosemirror@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@tommoor/y-prosemirror/-/y-prosemirror-0.4.1.tgz#ce3209b09cf090c1125f84c2b8a9456b26aa59e0"
integrity sha512-J6KoVtBJKRKrQCzLVAXfbOGh6eD00GiPhPiLK+42RyFSPy4DliFJ9SPgHxaDHOPki70NdSbJ+vy9L8TTV6y5yw==
dependencies:
"@rollup/plugin-commonjs" "^15.1.0"
"@rollup/plugin-node-resolve" "^9.0.0"
lib0 "^0.2.34"
lodash.flatten "^4.4.0"
rollup "^2.32.0"
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
version "7.1.9"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d"
@@ -1510,6 +1555,16 @@
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
"@types/estree@*":
version "0.0.45"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.45.tgz#e9387572998e5ecdac221950dab3e8c3b16af884"
integrity sha512-jnqIUKDUqJbDIUxm0Uj7bnlMnRm1T/eZ9N+AVMqhPgzrba2GhGG5o/jCTwmdPK709nEZsGoMzXEDUjcXHa3W0g==
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
"@types/events@*":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
@@ -1580,6 +1635,13 @@
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.0.2.tgz#5bb52ee68d0f8efa9cc0099920e56be6cc4e37f3"
integrity sha512-IkVfat549ggtkZUthUzEX49562eGikhSYeVGX97SkMFn+sTZrgRewXjQ4tPKFPCykZHkX1Zfd9OoELGqKU2jJA==
"@types/resolve@1.17.1":
version "1.17.1"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
dependencies:
"@types/node" "*"
"@types/stack-utils@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
@@ -2762,6 +2824,11 @@ builtin-modules@^2.0.0:
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-2.0.0.tgz#60b7ef5ae6546bd7deefa74b08b62a43a232648e"
integrity sha512-3U5kUA5VPsRUA3nofm/BXX7GVHKfxz0hOBAPxXrIvHzlDRkQVqEn6yi8QJegxl4LzOHLdvb7XF5dVawa/VVYBg==
builtin-modules@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484"
integrity sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==
builtin-status-codes@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
@@ -4527,6 +4594,16 @@ estraverse@^5.1.0:
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642"
integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw==
estree-walker@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
estree-walker@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.1.tgz#f8e030fb21cefa183b44b7ad516b747434e7a3e0"
integrity sha512-tF0hv+Yi2Ot1cwj9eYHtxC0jB9bmjacjQs6ZBTj82H8JwUywFuc+7E83NWfNMwHXZc11mjfFcVXPe9gEP4B8dg==
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
@@ -5167,7 +5244,7 @@ glob-parent@^5.0.0, glob-parent@~5.1.0:
dependencies:
is-glob "^4.0.1"
glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4:
glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
version "7.1.6"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -6173,6 +6250,13 @@ is-redirect@^1.0.0:
resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
is-reference@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7"
integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==
dependencies:
"@types/estree" "*"
is-regex@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae"
@@ -6274,6 +6358,11 @@ isomorphic-fetch@2.2.1, isomorphic-fetch@^2.2.1:
node-fetch "^1.0.1"
whatwg-fetch ">=0.10.0"
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"
@@ -7225,6 +7314,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"
@@ -7599,6 +7695,13 @@ macos-release@^2.2.0:
resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f"
integrity sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA==
magic-string@^0.25.7:
version "0.25.7"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051"
integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==
dependencies:
sourcemap-codec "^1.4.4"
make-dir@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
@@ -8843,7 +8946,7 @@ pgpass@1.x:
dependencies:
split "^1.0.0"
picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1:
picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
@@ -9995,6 +10098,13 @@ rollup@^0.41.4:
dependencies:
source-map-support "^0.4.0"
rollup@^2.32.0:
version "2.32.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.32.1.tgz#625a92c54f5b4d28ada12d618641491d4dbb548c"
integrity sha512-Op2vWTpvK7t6/Qnm1TTh7VjEZZkN8RWgf0DHbkKzQBwNf748YhXbozHVefqpPp/Fuyk/PQPAnYsBxAEtlMvpUw==
optionalDependencies:
fsevents "~2.1.2"
rope-sequence@^1.3.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.2.tgz#a19e02d72991ca71feb6b5f8a91154e48e3c098b"
@@ -10559,6 +10669,11 @@ source-map@~0.4.1:
dependencies:
amdefine ">=0.0.4"
sourcemap-codec@^1.4.4:
version "1.4.8"
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
space-separated-tokens@^1.0.0:
version "1.1.5"
resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899"
@@ -12133,6 +12248,20 @@ 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-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@^3.2.1 || ^4.0.0", y18n@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
@@ -12238,6 +12367,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"