mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35a4dd19e6 | |||
| 750d9ab4c6 | |||
| cdf0df0faa | |||
| 48f54b5aa2 | |||
| 2ca57fc7cf | |||
| 470920e2c3 | |||
| beee8ebee7 | |||
| 9f05c9bd43 | |||
| 65be808556 | |||
| 89f8df619c | |||
| 756ec92cdb | |||
| a8e2e349e9 | |||
| 25f745e7e5 | |||
| 07b1811993 | |||
| d71f0ae6bd | |||
| f58032d305 | |||
| 6beb6febc4 | |||
| a6d4d4ea36 | |||
| a99f6bed42 | |||
| 4cd61db1ea | |||
| 0db7bb7f3e | |||
| d8ca9c6111 | |||
| 4a8d357084 | |||
| e0fb76cb63 | |||
| ffed38bf71 | |||
| b4c08a027b | |||
| d40e60675d | |||
| 76169c1bf2 | |||
| 8ed030e2ec | |||
| a55163fb00 | |||
| b05a36d450 | |||
| dde6c3e443 | |||
| e861884b4e | |||
| 4ca2d3776e | |||
| 7c0ddf7efb | |||
| 6842ea4a35 | |||
| 7e0ebc6b4e | |||
| c0a322bc20 | |||
| 769b0225e2 | |||
| 9bcf5b0292 | |||
| 3b4aa02c67 | |||
| 375d658231 | |||
| 70ea77ce01 | |||
| cea1d808d1 | |||
| bea8b85cf9 | |||
| abeccb8a4c | |||
| c8d3d26044 | |||
| ec5a7d79f5 | |||
| 30d31b35ac | |||
| 8abf2436dd | |||
| 4df75bda7b | |||
| 220546c40a | |||
| b96ffe59db | |||
| 2676a7e8cf | |||
| 5e9e4fb028 | |||
| 551b1620e0 | |||
| b8569ed8de | |||
| 8d1a707dd0 | |||
| 9877cf1f4e | |||
| 50fbcd8d85 | |||
| 359d228771 | |||
| 0347620c75 | |||
| cb362511a5 | |||
| 700db463fc | |||
| a28dfa77ee | |||
| d8bc6515dd | |||
| 0776b78e25 | |||
| 4256e7ec87 | |||
| e723124f8f | |||
| c2fbd78622 | |||
| 17cbeab409 | |||
| 37d456a0fb | |||
| 48a0ba0dec | |||
| f454467bf1 | |||
| f21f660543 | |||
| 50637bc7ce | |||
| acb61d5e0c | |||
| 7dcbaa9c5c | |||
| ae1761e517 | |||
| 7166378c32 | |||
| 2719321430 | |||
| a7f2c7edb3 |
@@ -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
|
||||
|
||||
|
||||
@@ -96,7 +96,8 @@ For contributing features and fixes you can quickly get an environment running u
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. Configure your Slack app's Oauth & Permissions settings
|
||||
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
|
||||
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ type Props = {
|
||||
|
||||
export default class Analytics extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) return;
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// standard Google Analytics script
|
||||
window.ga =
|
||||
|
||||
@@ -101,7 +101,8 @@ export const MenuAnchor = styled.a`
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:hover,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
@@ -112,11 +113,6 @@ export const MenuAnchor = styled.a`
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
}
|
||||
`};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -15,6 +15,7 @@ class CopyToClipboard extends React.PureComponent<Props> {
|
||||
const elem = React.Children.only(children);
|
||||
copy(text, {
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
|
||||
if (onCopy) onCopy();
|
||||
|
||||
@@ -67,6 +67,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
id: document.collectionId,
|
||||
name: t("Deleted Collection"),
|
||||
color: "currentColor",
|
||||
url: "deleted-collection",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
output.push({
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
title: collection.name,
|
||||
to: collectionUrl(collection.id),
|
||||
to: collectionUrl(collection.url),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,7 +105,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
}, [path, category, collection]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onlyText === true) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -37,7 +37,7 @@ class RevisionListItem extends React.Component<Props> {
|
||||
</Author>
|
||||
<Meta>
|
||||
<Time dateTime={revision.createdAt} tooltipDelay={250}>
|
||||
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
|
||||
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
|
||||
</Time>
|
||||
</Meta>
|
||||
{showMenu && (
|
||||
|
||||
@@ -23,6 +23,12 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
const totalViewers = documentViews.length;
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!document.isDeleted) {
|
||||
views.fetchPage({ documentId: document.id });
|
||||
}
|
||||
}, [views, document.id, document.isDeleted]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 8,
|
||||
placement: "bottom",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -19,10 +19,6 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { views, presence } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
views.fetchPage({ documentId: document.id });
|
||||
}, [views, document.id]);
|
||||
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
@@ -59,7 +55,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
timeAgo: distanceInWordsToNow(
|
||||
timeAgo: formatDistanceToNow(
|
||||
view ? new Date(view.lastViewedAt) : new Date()
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
// }
|
||||
|
||||
@@ -16,6 +16,10 @@ const Select = styled.select`
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
option {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
|
||||
@@ -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,20 +1,21 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import format from "date-fns/format";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { enUS, de, fr, es, it, ko, ptBR, pt, zhCN, ru } from "date-fns/locale";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useUserLocale from "hooks/useUserLocale";
|
||||
|
||||
const locales = {
|
||||
en: require(`date-fns/locale/en`),
|
||||
de: require(`date-fns/locale/de`),
|
||||
es: require(`date-fns/locale/es`),
|
||||
fr: require(`date-fns/locale/fr`),
|
||||
it: require(`date-fns/locale/it`),
|
||||
ko: require(`date-fns/locale/ko`),
|
||||
pt: require(`date-fns/locale/pt`),
|
||||
zh: require(`date-fns/locale/zh_cn`),
|
||||
ru: require(`date-fns/locale/ru`),
|
||||
en_US: enUS,
|
||||
de_DE: de,
|
||||
es_ES: es,
|
||||
fr_FR: fr,
|
||||
it_IT: it,
|
||||
ko_KR: ko,
|
||||
pt_BR: ptBR,
|
||||
pt_PT: pt,
|
||||
zh_CN: zhCN,
|
||||
ru_RU: ru,
|
||||
};
|
||||
|
||||
let callbacks = [];
|
||||
@@ -64,7 +65,7 @@ function LocaleTime({
|
||||
};
|
||||
}, []);
|
||||
|
||||
let content = distanceInWordsToNow(dateTime, {
|
||||
let content = formatDistanceToNow(Date.parse(dateTime), {
|
||||
addSuffix,
|
||||
locale: userLocale ? locales[userLocale] : undefined,
|
||||
});
|
||||
@@ -78,7 +79,7 @@ function LocaleTime({
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
|
||||
delay={tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
@@ -139,30 +139,32 @@ const Link = styled(NavLink)`
|
||||
transition: fill 50ms;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.text};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.black05};
|
||||
}
|
||||
|
||||
&:hover + ${Actions},
|
||||
&:active + ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 32px 4px 16px;
|
||||
font-size: 15px;
|
||||
`}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover + ${Actions},
|
||||
&:active + ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import * as React from "react";
|
||||
|
||||
const LocaleTime = React.lazy(() =>
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function Time(props: Props) {
|
||||
let content = distanceInWordsToNow(props.dateTime, {
|
||||
let content = formatDistanceToNow(Date.parse(props.dateTime), {
|
||||
addSuffix: props.addSuffix,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@ export default function useUserLocale() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return auth.user.language.split("_")[0];
|
||||
return auth.user.language;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// @flow
|
||||
// Based on https://github.com/rehooks/window-scroll-position which is no longer
|
||||
// maintained.
|
||||
import { throttle } from "lodash";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
let supportsPassive = false;
|
||||
try {
|
||||
var opts = Object.defineProperty({}, "passive", {
|
||||
get: function () {
|
||||
supportsPassive = true;
|
||||
},
|
||||
});
|
||||
window.addEventListener("testPassive", null, opts);
|
||||
window.removeEventListener("testPassive", null, opts);
|
||||
} catch (e) {}
|
||||
|
||||
const getPosition = () => ({
|
||||
x: window.pageXOffset,
|
||||
y: window.pageYOffset,
|
||||
});
|
||||
|
||||
const defaultOptions = {
|
||||
throttle: 100,
|
||||
};
|
||||
|
||||
export default function useWindowScrollPosition(options: {
|
||||
throttle: number,
|
||||
}): { x: number, y: number } {
|
||||
let opts = Object.assign({}, defaultOptions, options);
|
||||
|
||||
let [position, setPosition] = useState(getPosition());
|
||||
|
||||
useEffect(() => {
|
||||
let handleScroll = throttle(() => {
|
||||
setPosition(getPosition());
|
||||
}, opts.throttle);
|
||||
|
||||
window.addEventListener(
|
||||
"scroll",
|
||||
handleScroll,
|
||||
supportsPassive ? { passive: true } : false
|
||||
);
|
||||
|
||||
return () => {
|
||||
handleScroll.cancel();
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [opts.throttle]);
|
||||
|
||||
return position;
|
||||
}
|
||||
+1
-1
@@ -81,7 +81,7 @@ window.addEventListener("load", async () => {
|
||||
if (!env.GOOGLE_ANALYTICS_ID || !window.ga) return;
|
||||
|
||||
// https://github.com/googleanalytics/autotrack/issues/137#issuecomment-305890099
|
||||
await import(/** webpackChunkName: "autotrack" */ "autotrack/autotrack.js");
|
||||
await import(/* webpackChunkName: "autotrack" */ "autotrack/autotrack.js");
|
||||
|
||||
window.ga("require", "outboundLinkTracker");
|
||||
window.ga("require", "urlChangeTracker");
|
||||
|
||||
@@ -218,12 +218,7 @@ function DocumentMenu({
|
||||
items={[
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!can.unarchive,
|
||||
onClick: handleRestore,
|
||||
},
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!(collection && can.restore),
|
||||
visible: (!!collection && can.restore) || can.unarchive,
|
||||
onClick: handleRestore,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ function NewDocumentMenu() {
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
if (!can.createDocument) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (singleCollection) {
|
||||
|
||||
@@ -23,7 +23,7 @@ function NewTemplateMenu() {
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
if (!can.createDocument) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+32
-16
@@ -9,6 +9,7 @@ import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import MenuItem from "components/ContextMenu/MenuItem";
|
||||
import Separator from "components/ContextMenu/Separator";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
@@ -19,12 +20,36 @@ function TemplatesMenu({ document }: Props) {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const templates = documents.templatesInCollection(document.collectionId);
|
||||
const templates = documents.templates;
|
||||
|
||||
if (!templates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templatesInCollection = templates.filter(
|
||||
(t) => t.collectionId === document.collectionId
|
||||
);
|
||||
const otherTemplates = templates.filter(
|
||||
(t) => t.collectionId !== document.collectionId
|
||||
);
|
||||
|
||||
const renderTemplate = (template) => (
|
||||
<MenuItem
|
||||
key={template.id}
|
||||
onClick={() => document.updateFromTemplate(template)}
|
||||
{...menu}
|
||||
>
|
||||
<DocumentIcon />
|
||||
<div>
|
||||
<strong>{template.titleWithDefault}</strong>
|
||||
<br />
|
||||
<Author>
|
||||
{t("By {{ author }}", { author: template.createdBy.name })}
|
||||
</Author>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
@@ -35,21 +60,11 @@ function TemplatesMenu({ document }: Props) {
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Templates")}>
|
||||
{templates.map((template) => (
|
||||
<MenuItem
|
||||
key={template.id}
|
||||
onClick={() => document.updateFromTemplate(template)}
|
||||
>
|
||||
<DocumentIcon />
|
||||
<div>
|
||||
<strong>{template.titleWithDefault}</strong>
|
||||
<br />
|
||||
<Author>
|
||||
{t("By {{ author }}", { author: template.createdBy.name })}
|
||||
</Author>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
{templatesInCollection.map(renderTemplate)}
|
||||
{otherTemplates.length && templatesInCollection.length ? (
|
||||
<Separator />
|
||||
) : undefined}
|
||||
{otherTemplates.map(renderTemplate)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
@@ -57,6 +72,7 @@ function TemplatesMenu({ document }: Props) {
|
||||
|
||||
const Author = styled.div`
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
export default observer(TemplatesMenu);
|
||||
|
||||
@@ -24,6 +24,7 @@ export default class Collection extends BaseModel {
|
||||
deletedAt: ?string;
|
||||
sort: { field: string, direction: "asc" | "desc" };
|
||||
url: string;
|
||||
urlId: string;
|
||||
|
||||
@computed
|
||||
get isEmpty(): boolean {
|
||||
|
||||
+24
-3
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import addDays from "date-fns/add_days";
|
||||
import differenceInDays from "date-fns/difference_in_days";
|
||||
import { addDays, differenceInDays } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import { action, computed, observable, set } from "mobx";
|
||||
import parseTitle from "shared/utils/parseTitle";
|
||||
@@ -77,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
|
||||
);
|
||||
@@ -232,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;
|
||||
@@ -265,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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ class Team extends BaseModel {
|
||||
avatarUrl: string;
|
||||
sharing: boolean;
|
||||
documentEmbeds: boolean;
|
||||
multiplayerEditor: boolean;
|
||||
guestSignin: boolean;
|
||||
subdomain: ?string;
|
||||
domain: ?string;
|
||||
|
||||
@@ -8,6 +8,7 @@ class User extends BaseModel {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
color: string;
|
||||
isAdmin: boolean;
|
||||
isViewer: boolean;
|
||||
lastActiveAt: string;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-37
@@ -3,7 +3,6 @@ import * as React from "react";
|
||||
import { Switch, Redirect, type Match } from "react-router-dom";
|
||||
import Archive from "scenes/Archive";
|
||||
import Collection from "scenes/Collection";
|
||||
import KeyedDocument from "scenes/Document/KeyedDocument";
|
||||
import DocumentNew from "scenes/DocumentNew";
|
||||
import Drafts from "scenes/Drafts";
|
||||
import Error404 from "scenes/Error404";
|
||||
@@ -23,7 +22,9 @@ import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||
const SettingsRoutes = React.lazy(() =>
|
||||
import(/* webpackChunkName: "settings" */ "./settings")
|
||||
);
|
||||
|
||||
const Document = React.lazy(() =>
|
||||
import(/* webpackChunkName: "document" */ "scenes/Document")
|
||||
);
|
||||
const NotFound = () => <Search notFound />;
|
||||
const RedirectDocument = ({ match }: { match: Match }) => (
|
||||
<Redirect
|
||||
@@ -37,42 +38,44 @@ export default function AuthenticatedRoutes() {
|
||||
return (
|
||||
<SocketProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Home} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Route exact path="/collections/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collections/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collections/:id" component={Collection} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Home} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collection/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collection/:id" component={Collection} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={Document}
|
||||
/>
|
||||
<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} />
|
||||
|
||||
<SettingsRoutes />
|
||||
</React.Suspense>
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
<Route component={NotFound} />
|
||||
</Switch>{" "}
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</SocketProvider>
|
||||
);
|
||||
|
||||
+4
-6
@@ -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,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} />
|
||||
|
||||
+46
-25
@@ -4,7 +4,15 @@ import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useParams, Redirect, Link, Switch, Route } from "react-router-dom";
|
||||
import {
|
||||
useParams,
|
||||
Redirect,
|
||||
Link,
|
||||
Switch,
|
||||
Route,
|
||||
useHistory,
|
||||
useRouteMatch,
|
||||
} from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import CollectionPermissions from "scenes/CollectionPermissions";
|
||||
import Search from "scenes/Search";
|
||||
@@ -29,15 +37,18 @@ import Subheading from "components/Subheading";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import Collection from "../models/Collection";
|
||||
import { updateCollectionUrl } from "../utils/routeHelpers";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useImportDocument from "hooks/useImportDocument";
|
||||
import useStores from "hooks/useStores";
|
||||
import useUnmount from "hooks/useUnmount";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
function CollectionScene() {
|
||||
const params = useParams();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
const { t } = useTranslation();
|
||||
const { documents, policies, collections, ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
@@ -45,11 +56,21 @@ function CollectionScene() {
|
||||
const [error, setError] = React.useState();
|
||||
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
|
||||
|
||||
const collectionId = params.id || "";
|
||||
const collection = collections.get(collectionId);
|
||||
const can = policies.abilities(collectionId || "");
|
||||
const id = params.id || "";
|
||||
const collection: ?Collection =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const can = policies.abilities(collection?.id || "");
|
||||
const canUser = policies.abilities(team.id);
|
||||
const { handleFiles, isImporting } = useImportDocument(collectionId);
|
||||
const { handleFiles, isImporting } = useImportDocument(collection?.id || "");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection) {
|
||||
const canonicalUrl = updateCollectionUrl(match.url, collection);
|
||||
if (match.url !== canonicalUrl) {
|
||||
history.replace(canonicalUrl);
|
||||
}
|
||||
}
|
||||
}, [collection, history, id, match.url]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection) {
|
||||
@@ -59,8 +80,10 @@ function CollectionScene() {
|
||||
|
||||
React.useEffect(() => {
|
||||
setError(null);
|
||||
documents.fetchPinned({ collectionId });
|
||||
}, [documents, collectionId]);
|
||||
if (collection) {
|
||||
documents.fetchPinned({ collectionId: collection.id });
|
||||
}
|
||||
}, [documents, collection]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
@@ -68,7 +91,7 @@ function CollectionScene() {
|
||||
try {
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
await collections.fetch(collectionId);
|
||||
await collections.fetch(id);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
@@ -77,9 +100,7 @@ function CollectionScene() {
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [collections, isFetching, collection, error, collectionId, can]);
|
||||
|
||||
useUnmount(ui.clearActiveCollection);
|
||||
}, [collections, isFetching, collection, error, id, can]);
|
||||
|
||||
const handlePermissionsModalOpen = React.useCallback(() => {
|
||||
setPermissionsModalOpen(true);
|
||||
@@ -124,7 +145,7 @@ function CollectionScene() {
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
label={`${t("Search in collection")}…`}
|
||||
collectionId={collectionId}
|
||||
collectionId={collection.id}
|
||||
/>
|
||||
</Action>
|
||||
{can.update && (
|
||||
@@ -257,27 +278,27 @@ function CollectionScene() {
|
||||
)}
|
||||
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.id)} exact>
|
||||
<Tab to={collectionUrl(collection.url)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
||||
<Tab to={collectionUrl(collection.url, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
||||
<Tab to={collectionUrl(collection.url, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
||||
<Tab to={collectionUrl(collection.url, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab
|
||||
to={collectionUrl(collection.id, "alphabetical")}
|
||||
to={collectionUrl(collection.url, "alphabetical")}
|
||||
exact
|
||||
>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.id, "alphabetical")}>
|
||||
<Route path={collectionUrl(collection.url, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
@@ -288,7 +309,7 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "old")}>
|
||||
<Route path={collectionUrl(collection.url, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
@@ -299,12 +320,12 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "recent")}>
|
||||
<Route path={collectionUrl(collection.url, "recent")}>
|
||||
<Redirect
|
||||
to={collectionUrl(collection.id, "published")}
|
||||
to={collectionUrl(collection.url, "published")}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "published")}>
|
||||
<Route path={collectionUrl(collection.url, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
@@ -316,7 +337,7 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "updated")}>
|
||||
<Route path={collectionUrl(collection.url, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
@@ -327,7 +348,7 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)} exact>
|
||||
<Route path={collectionUrl(collection.url)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// @flow
|
||||
import useWindowScrollPosition from "@rehooks/window-scroll-position";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import HelpText from "components/HelpText";
|
||||
import useWindowScrollPosition from "hooks/useWindowScrollPosition";
|
||||
|
||||
const HEADING_OFFSET = 20;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
@@ -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 = {};
|
||||
@@ -94,8 +94,11 @@ class DataLoader extends React.Component<Props> {
|
||||
// search for exact internal document
|
||||
const slug = parseDocumentSlug(term);
|
||||
try {
|
||||
const { document } = await this.props.documents.fetch(slug);
|
||||
const time = distanceInWordsToNow(document.updatedAt, {
|
||||
const {
|
||||
document,
|
||||
}: { document: Document } = await this.props.documents.fetch(slug);
|
||||
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
return [
|
||||
@@ -118,7 +121,7 @@ class DataLoader extends React.Component<Props> {
|
||||
|
||||
return sortBy(
|
||||
results.map((document) => {
|
||||
const time = distanceInWordsToNow(document.updatedAt, {
|
||||
const time = formatDistanceToNow(document.updatedAt, {
|
||||
addSuffix: true,
|
||||
});
|
||||
return {
|
||||
@@ -211,10 +214,7 @@ class DataLoader extends React.Component<Props> {
|
||||
const isMove = this.props.location.pathname.match(/move$/);
|
||||
const canRedirect = !revisionId && !isMove && !shareId;
|
||||
if (canRedirect) {
|
||||
const canonicalUrl = updateDocumentUrl(
|
||||
this.props.match.url,
|
||||
document.url
|
||||
);
|
||||
const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
|
||||
if (this.props.location.pathname !== canonicalUrl) {
|
||||
this.props.history.replace(canonicalUrl);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +33,12 @@ 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";
|
||||
import { meta } from "utils/keyboard";
|
||||
import {
|
||||
collectionUrl,
|
||||
documentMoveUrl,
|
||||
documentHistoryUrl,
|
||||
editDocumentUrl,
|
||||
@@ -55,7 +57,6 @@ Are you sure you want to discard them?
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
match: Match,
|
||||
history: RouterHistory,
|
||||
location: LocationWithState,
|
||||
sharedTree: ?NavigationNode,
|
||||
@@ -63,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,
|
||||
@@ -174,7 +183,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.onSave({ publish: true, done: true });
|
||||
}
|
||||
|
||||
@keydown(`${meta}+ctrl+h`)
|
||||
@keydown("ctrl+alt+h")
|
||||
onToggleTableOfContents(ev) {
|
||||
if (!this.props.readOnly) return;
|
||||
|
||||
@@ -195,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;
|
||||
@@ -223,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;
|
||||
|
||||
@@ -271,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,
|
||||
@@ -291,15 +317,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
};
|
||||
|
||||
goBack = () => {
|
||||
let url;
|
||||
if (this.props.document.url) {
|
||||
url = this.props.document.url;
|
||||
} else if (this.props.match.params.id) {
|
||||
url = collectionUrl(this.props.match.params.id);
|
||||
}
|
||||
if (url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
this.props.history.push(this.props.document.url);
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -307,9 +325,10 @@ class DocumentScene extends React.Component<Props> {
|
||||
document,
|
||||
revision,
|
||||
readOnly,
|
||||
abilities,
|
||||
abilities = {},
|
||||
auth,
|
||||
ui,
|
||||
multiplayer,
|
||||
match,
|
||||
} = this.props;
|
||||
const team = auth.team;
|
||||
@@ -335,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}`}
|
||||
@@ -359,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
|
||||
@@ -418,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 you’re 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}
|
||||
@@ -445,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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -132,24 +147,27 @@ class DocumentEditor extends React.Component<Props> {
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
)}
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={documentHistoryUrl(document)}
|
||||
/>
|
||||
<Editor
|
||||
{!shareId && (
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={documentHistoryUrl(document)}
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
|
||||
@@ -83,7 +83,7 @@ function DocumentHeader({
|
||||
const toc = (
|
||||
<Tooltip
|
||||
tooltip={ui.tocVisible ? t("Hide contents") : t("Show contents")}
|
||||
shortcut={`ctrl+${metaDisplay}+h`}
|
||||
shortcut="ctrl+alt+h"
|
||||
delay={250}
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import { GlobeIcon, PadlockIcon } from "outline-icons";
|
||||
@@ -118,9 +118,12 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
|
||||
<>
|
||||
.{" "}
|
||||
{t("The shared link was last accessed {{ timeAgo }}.", {
|
||||
timeAgo: distanceInWordsToNow(share.lastAccessedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
timeAgo: formatDistanceToNow(
|
||||
Date.parse(share.lastAccessedAt),
|
||||
{
|
||||
addSuffix: true,
|
||||
}
|
||||
),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ type Props = {
|
||||
|
||||
function DocumentDelete({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { ui, documents } = useStores();
|
||||
const { ui, documents, collections } = useStores();
|
||||
const history = useHistory();
|
||||
const [isDeleting, setDeleting] = React.useState(false);
|
||||
const [isArchiving, setArchiving] = React.useState(false);
|
||||
const { showToast } = ui;
|
||||
const canArchive = !document.isDraft && !document.isArchived;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
@@ -45,7 +46,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
}
|
||||
|
||||
// otherwise, redirect to the collection home
|
||||
history.push(collectionUrl(document.collectionId));
|
||||
history.push(collectionUrl(collection?.url || "/"));
|
||||
}
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
@@ -54,7 +55,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
setDeleting(false);
|
||||
}
|
||||
},
|
||||
[showToast, onSubmit, ui, document, documents, history]
|
||||
[showToast, onSubmit, ui, document, documents, history, collection]
|
||||
);
|
||||
|
||||
const handleArchive = React.useCallback(
|
||||
|
||||
+43
-44
@@ -1,58 +1,57 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type RouterHistory,
|
||||
type Location,
|
||||
type Match,
|
||||
} from "react-router-dom";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Flex from "components/Flex";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import useStores from "hooks/useStores";
|
||||
import { editDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
location: Location,
|
||||
documents: DocumentsStore,
|
||||
ui: UiStore,
|
||||
match: Match,
|
||||
};
|
||||
function DocumentNew() {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const { t } = useTranslation();
|
||||
const { documents, ui, collections } = useStores();
|
||||
const id = match.params.id || "";
|
||||
|
||||
class DocumentNew extends React.Component<Props> {
|
||||
async componentDidMount() {
|
||||
const params = queryString.parse(this.props.location.search);
|
||||
useEffect(() => {
|
||||
async function createDocument() {
|
||||
const params = queryString.parse(location.search);
|
||||
try {
|
||||
const collection = await collections.fetch(id);
|
||||
|
||||
try {
|
||||
const document = await this.props.documents.create({
|
||||
collectionId: this.props.match.params.id,
|
||||
parentDocumentId: params.parentDocumentId,
|
||||
templateId: params.templateId,
|
||||
template: params.template,
|
||||
title: "",
|
||||
text: "",
|
||||
});
|
||||
this.props.history.replace(editDocumentUrl(document));
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Couldn’t create the document, try again?", {
|
||||
type: "error",
|
||||
});
|
||||
this.props.history.goBack();
|
||||
const document = await documents.create({
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: params.parentDocumentId,
|
||||
templateId: params.templateId,
|
||||
template: params.template,
|
||||
title: "",
|
||||
text: "",
|
||||
});
|
||||
|
||||
history.replace(editDocumentUrl(document));
|
||||
} catch (err) {
|
||||
ui.showToast(t("Couldn’t create the document, try again?"), {
|
||||
type: "error",
|
||||
});
|
||||
history.goBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
createDocument();
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Flex column auto>
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flex column auto>
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("documents", "ui")(DocumentNew);
|
||||
export default observer(DocumentNew);
|
||||
|
||||
@@ -33,7 +33,7 @@ function KeyboardShortcuts() {
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
<Key>Ctrl</Key> + <Key>{metaDisplay}</Key> + <Key>h</Key>
|
||||
<Key>Ctrl</Key> + <Key>Alt</Key> + <Key>h</Key>
|
||||
</>
|
||||
),
|
||||
label: t("Table of contents"),
|
||||
|
||||
@@ -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);
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -52,7 +52,7 @@ function UserProfile(props: Props) {
|
||||
? t("Joined")
|
||||
: t("Invited")}{" "}
|
||||
{t("{{ time }} ago.", {
|
||||
time: distanceInWordsToNow(new Date(user.createdAt)),
|
||||
time: formatDistanceToNow(new Date(user.createdAt)),
|
||||
})}
|
||||
{user.isAdmin && (
|
||||
<StyledBadge primary={user.isAdmin}>{t("Admin")}</StyledBadge>
|
||||
|
||||
@@ -139,7 +139,8 @@ export default class BaseStore<T: BaseModel> {
|
||||
throw new Error(`Cannot fetch ${this.modelName}`);
|
||||
}
|
||||
|
||||
let item = this.data.get(id);
|
||||
const item = this.data.get(id);
|
||||
|
||||
if (item && !options.force) return item;
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { concat, last } from "lodash";
|
||||
import { concat, find, last } from "lodash";
|
||||
import { computed, action } from "mobx";
|
||||
import Collection from "models/Collection";
|
||||
import BaseStore from "./BaseStore";
|
||||
@@ -126,6 +126,30 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
return result;
|
||||
}
|
||||
|
||||
@action
|
||||
async fetch(id: string, options: Object = {}): Promise<*> {
|
||||
const item = this.get(id) || this.getByUrl(id);
|
||||
|
||||
if (item && !options.force) return item;
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/collections.info`, { id });
|
||||
invariant(res && res.data, "Collection not available");
|
||||
|
||||
this.addPolicies(res.policies);
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 403) {
|
||||
this.remove(id);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
getPathForDocument(documentId: string): ?DocumentPath {
|
||||
return this.pathsToDocuments.find((path) => path.id === documentId);
|
||||
}
|
||||
@@ -135,6 +159,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
if (path) return path.title;
|
||||
}
|
||||
|
||||
getByUrl(url: string): ?Collection {
|
||||
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
|
||||
}
|
||||
|
||||
delete = async (collection: Collection) => {
|
||||
await super.delete(collection);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -108,15 +108,9 @@ class UiStore {
|
||||
this.activeCollectionId = collection.id;
|
||||
};
|
||||
|
||||
@action
|
||||
clearActiveCollection = (): void => {
|
||||
this.activeCollectionId = undefined;
|
||||
};
|
||||
|
||||
@action
|
||||
clearActiveDocument = (): void => {
|
||||
this.activeDocumentId = undefined;
|
||||
this.activeCollectionId = undefined;
|
||||
};
|
||||
|
||||
@action
|
||||
|
||||
+19
-13
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import queryString from "query-string";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
|
||||
export function homeUrl(): string {
|
||||
@@ -11,13 +12,23 @@ export function starredUrl(): string {
|
||||
}
|
||||
|
||||
export function newCollectionUrl(): string {
|
||||
return "/collections/new";
|
||||
return "/collection/new";
|
||||
}
|
||||
|
||||
export function collectionUrl(collectionId: string, section: ?string): string {
|
||||
const path = `/collections/${collectionId}`;
|
||||
if (section) return `${path}/${section}`;
|
||||
return path;
|
||||
export function collectionUrl(url: string, section: ?string): string {
|
||||
if (section) return `${url}/${section}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function updateCollectionUrl(
|
||||
oldUrl: string,
|
||||
collection: Collection
|
||||
): string {
|
||||
// Update url to match the current one
|
||||
return oldUrl.replace(
|
||||
new RegExp("/collection/[0-9a-zA-Z-_~]*"),
|
||||
collection.url
|
||||
);
|
||||
}
|
||||
|
||||
export function documentUrl(doc: Document): string {
|
||||
@@ -42,14 +53,9 @@ export function documentHistoryUrl(doc: Document, revisionId?: string): string {
|
||||
* Replace full url's document part with the new one in case
|
||||
* the document slug has been updated
|
||||
*/
|
||||
export function updateDocumentUrl(oldUrl: string, newUrl: string): string {
|
||||
export function updateDocumentUrl(oldUrl: string, document: Document): string {
|
||||
// Update url to match the current one
|
||||
const urlParts = oldUrl.trim().split("/");
|
||||
const actions = urlParts.slice(3);
|
||||
if (actions[0]) {
|
||||
return [newUrl, actions].join("/");
|
||||
}
|
||||
return newUrl;
|
||||
return oldUrl.replace(new RegExp("/doc/[0-9a-zA-Z-_~]*"), document.url);
|
||||
}
|
||||
|
||||
export function newDocumentUrl(
|
||||
@@ -60,7 +66,7 @@ export function newDocumentUrl(
|
||||
template?: boolean,
|
||||
}
|
||||
): string {
|
||||
return `/collections/${collectionId}/new?${queryString.stringify(params)}`;
|
||||
return `/collection/${collectionId}/new?${queryString.stringify(params)}`;
|
||||
}
|
||||
|
||||
export function searchUrl(
|
||||
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
// flow-typed signature: 350413ab85bd03f3d1450c0ae307d106
|
||||
// flow-typed version: c6154227d1/copy-to-clipboard_v3.x.x/flow_>=v0.104.x
|
||||
|
||||
declare module 'copy-to-clipboard' {
|
||||
// @flow
|
||||
declare module "copy-to-clipboard" {
|
||||
declare export type Options = {|
|
||||
debug?: boolean,
|
||||
message?: string,
|
||||
format?: "text/plain" | "text/html",
|
||||
|};
|
||||
|
||||
declare module.exports: (text: string, options?: Options) => boolean;
|
||||
|
||||
Vendored
+377
@@ -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">;
|
||||
}
|
||||
Vendored
+39
@@ -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
@@ -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">;
|
||||
}
|
||||
Vendored
+67
@@ -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">;
|
||||
}
|
||||
Vendored
+430
@@ -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'>;
|
||||
}
|
||||
+22
-17
@@ -73,12 +73,12 @@
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@outlinewiki/koa-passport": "^4.1.4",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@rehooks/window-scroll-position": "^1.0.1",
|
||||
"@sentry/node": "^6.3.1",
|
||||
"@sentry/react": "^6.3.1",
|
||||
"@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",
|
||||
@@ -90,9 +90,9 @@
|
||||
"cancan": "3.1.0",
|
||||
"chalk": "^4.1.0",
|
||||
"compressorjs": "^1.0.7",
|
||||
"copy-to-clipboard": "^3.0.6",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"core-js": "^3.10.2",
|
||||
"date-fns": "1.29.0",
|
||||
"date-fns": "2.22.1",
|
||||
"dd-trace": "^0.32.2",
|
||||
"debug": "^4.1.1",
|
||||
"dotenv": "^4.0.0",
|
||||
@@ -101,7 +101,7 @@
|
||||
"exports-loader": "^0.6.4",
|
||||
"fetch-with-proxy": "^3.0.1",
|
||||
"file-loader": "^1.1.6",
|
||||
"flow-typed": "^2.6.2",
|
||||
"flow-typed": "^3.3.1",
|
||||
"focus-visible": "^5.1.0",
|
||||
"fractional-index": "^1.0.0",
|
||||
"fs-extra": "^4.0.2",
|
||||
@@ -131,10 +131,11 @@
|
||||
"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.6.0",
|
||||
"mobx-react": "^6.2.5",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"nodemailer": "^6.4.16",
|
||||
"outline-icons": "^1.27.0",
|
||||
@@ -149,26 +150,27 @@
|
||||
"quoted-printable": "^1.0.1",
|
||||
"randomstring": "1.1.5",
|
||||
"raw-loader": "^0.5.1",
|
||||
"react": "^16.8.6",
|
||||
"react": "^17.0.2",
|
||||
"react-autosize-textarea": "^6.0.0",
|
||||
"react-avatar-editor": "^10.3.0",
|
||||
"react-avatar-editor": "^11.1.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-dnd": "^14.0.1",
|
||||
"react-dnd-html5-backend": "^14.0.0",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropzone": "^11.3.2",
|
||||
"react-helmet": "^5.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-i18next": "^11.7.3",
|
||||
"react-is": "^17.0.2",
|
||||
"react-keydown": "^1.7.3",
|
||||
"react-portal": "^4.0.0",
|
||||
"react-portal": "^4.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-table": "^7.7.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"react-waypoint": "^9.0.2",
|
||||
"react-virtualized-auto-sizer": "^1.0.5",
|
||||
"react-waypoint": "^10.1.0",
|
||||
"react-window": "^1.8.6",
|
||||
"reakit": "^1.3.6",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rich-markdown-editor": "^11.9.1",
|
||||
"rich-markdown-editor": "^11.10.0",
|
||||
"semver": "^7.3.2",
|
||||
"sequelize": "^6.3.4",
|
||||
"sequelize-cli": "^6.2.0",
|
||||
@@ -181,7 +183,7 @@
|
||||
"socket.io-redis": "^5.4.0",
|
||||
"socketio-auth": "^0.1.1",
|
||||
"string-replace-to-array": "^1.0.3",
|
||||
"styled-components": "^5.0.0",
|
||||
"styled-components": "^5.2.3",
|
||||
"styled-components-breakpoint": "^2.1.1",
|
||||
"styled-normalize": "^8.0.4",
|
||||
"tiny-cookie": "^2.3.1",
|
||||
@@ -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",
|
||||
@@ -216,7 +221,7 @@
|
||||
"koa-webpack-hot-middleware": "^1.0.3",
|
||||
"nodemon": "^1.19.4",
|
||||
"prettier": "^2.0.5",
|
||||
"react-refresh": "^0.10.0",
|
||||
"react-refresh": "^0.9.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"terser-webpack-plugin": "^4.1.0",
|
||||
"url-loader": "^0.6.2",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns";
|
||||
import Router from "koa-router";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { NotFoundError } from "../errors";
|
||||
@@ -39,7 +39,7 @@ router.post("attachments.create", auth(), async (ctx) => {
|
||||
const bucket = acl === "public-read" ? "public" : "uploads";
|
||||
const key = `${bucket}/${user.id}/${s3Key}/${name}`;
|
||||
const credential = makeCredential();
|
||||
const longDate = format(new Date(), "YYYYMMDDTHHmmss\\Z");
|
||||
const longDate = format(new Date(), "yyyyMMdd'T'HHmmss'Z'");
|
||||
const policy = makePolicy(credential, longDate, acl, contentType);
|
||||
const endpoint = publicS3Endpoint();
|
||||
const url = `${endpoint}/${key}`;
|
||||
|
||||
@@ -115,7 +115,7 @@ router.post("collections.create", auth(), async (ctx) => {
|
||||
|
||||
router.post("collections.info", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertPresent(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.scope({
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("#collections.export", () => {
|
||||
createdById: user.id,
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: "read",
|
||||
permission: "read_write",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/collections.export", {
|
||||
@@ -305,7 +305,7 @@ describe("#collections.export", () => {
|
||||
await group.addUser(user, { through: { createdById: user.id } });
|
||||
|
||||
await collection.addGroup(group, {
|
||||
through: { permission: "read", createdById: user.id },
|
||||
through: { permission: "read_write", createdById: user.id },
|
||||
});
|
||||
|
||||
const res = await server.post("/api/collections.export", {
|
||||
|
||||
+47
-53
@@ -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]),
|
||||
|
||||
@@ -1588,7 +1588,7 @@ describe("#documents.restore", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.parentDocumentId).toEqual(undefined);
|
||||
expect(body.data.parentDocumentId).toEqual(null);
|
||||
expect(body.data.archivedAt).toEqual(null);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1
-1
@@ -258,7 +258,7 @@ router.post("users.activate", auth(), async (ctx) => {
|
||||
|
||||
router.post("users.invite", auth(), async (ctx) => {
|
||||
const { invites } = ctx.body;
|
||||
ctx.assertPresent(invites, "invites is required");
|
||||
ctx.assertArray(invites, "invites must be an array");
|
||||
|
||||
const { user } = ctx.state;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
|
||||
@@ -167,6 +167,17 @@ describe("#users.invite", () => {
|
||||
expect(body.data.sent.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should require invites to be an array", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.invite", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
invites: { email: "test@example.com", name: "Test", guest: false },
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.invite", {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import subDays from "date-fns/sub_days";
|
||||
import { subDays } from "date-fns";
|
||||
import debug from "debug";
|
||||
import Router from "koa-router";
|
||||
import { AuthenticationError } from "../errors";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import subDays from "date-fns/sub_days";
|
||||
import { subDays } from "date-fns";
|
||||
import TestServer from "fetch-test-server";
|
||||
import app from "../app";
|
||||
import { Attachment, Document } from "../models";
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// @flow
|
||||
import TestServer from "fetch-test-server";
|
||||
import app from "./app";
|
||||
import { buildShare, buildDocument } from "./test/factories";
|
||||
import { flushdb } from "./test/support";
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("/share/:id", () => {
|
||||
it("should return standard title in html when loading share", async () => {
|
||||
const share = await buildShare({ published: false });
|
||||
|
||||
const res = await server.get(`/share/${share.id}`);
|
||||
const body = await res.text();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toContain("<title>Outline</title>");
|
||||
});
|
||||
|
||||
it("should return standard title in html when share does not exist", async () => {
|
||||
const res = await server.get(`/share/junk`);
|
||||
const body = await res.text();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toContain("<title>Outline</title>");
|
||||
});
|
||||
|
||||
it("should return document title in html when loading published share", async () => {
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({ documentId: document.id });
|
||||
|
||||
const res = await server.get(`/share/${share.id}`);
|
||||
const body = await res.text();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toContain(`<title>${document.title}</title>`);
|
||||
});
|
||||
|
||||
it("should return document title in html when loading published share with nested doc route", async () => {
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({ documentId: document.id });
|
||||
|
||||
const res = await server.get(`/share/${share.id}/doc/test-Cl6g1AgPYn`);
|
||||
const body = await res.text();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toContain(`<title>${document.title}</title>`);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import addMonths from "date-fns/add_months";
|
||||
import { addMonths } from "date-fns";
|
||||
import debug from "debug";
|
||||
import Koa from "koa";
|
||||
import bodyParser from "koa-body";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import subMinutes from "date-fns/sub_minutes";
|
||||
import { subMinutes } from "date-fns";
|
||||
import Router from "koa-router";
|
||||
import { find } from "lodash";
|
||||
import { AuthorizationError } from "../../errors";
|
||||
|
||||
@@ -16,10 +16,10 @@ jest.mock("aws-sdk", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
flushdb();
|
||||
|
||||
// $FlowFixMe
|
||||
sendEmail.mockReset();
|
||||
|
||||
return flushdb();
|
||||
});
|
||||
|
||||
describe("accountProvisioner", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -173,7 +173,10 @@ export class Mailer {
|
||||
let smtpConfig = {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure:
|
||||
"SMTP_SECURE" in process.env
|
||||
? process.env.SMTP_SECURE === "true"
|
||||
: process.env.NODE_ENV === "production",
|
||||
auth: undefined,
|
||||
};
|
||||
|
||||
|
||||
+168
-65
@@ -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) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import { type Context } from "koa";
|
||||
import { isArrayLike } from "lodash";
|
||||
import validator from "validator";
|
||||
import { validateColorHex } from "../../shared/utils/color";
|
||||
import { validateIndexCharacters } from "../../shared/utils/indexCharacters";
|
||||
@@ -13,6 +14,12 @@ export default function validation() {
|
||||
}
|
||||
};
|
||||
|
||||
ctx.assertArray = (value, message) => {
|
||||
if (!isArrayLike(value)) {
|
||||
throw new ValidationError(message);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.assertIn = (value, options, message) => {
|
||||
if (!options.includes(value)) {
|
||||
throw new ValidationError(message);
|
||||
|
||||
@@ -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,13 +1,13 @@
|
||||
// @flow
|
||||
import { find, findIndex, concat, remove, uniq } from "lodash";
|
||||
import randomstring from "randomstring";
|
||||
import slug from "slug";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers";
|
||||
import { Op, DataTypes, sequelize } from "../sequelize";
|
||||
import slugify from "../utils/slugify";
|
||||
import CollectionUser from "./CollectionUser";
|
||||
import Document from "./Document";
|
||||
|
||||
slug.defaults.mode = "rfc3986";
|
||||
|
||||
const Collection = sequelize.define(
|
||||
"collection",
|
||||
{
|
||||
@@ -72,7 +72,9 @@ const Collection = sequelize.define(
|
||||
},
|
||||
getterMethods: {
|
||||
url() {
|
||||
return `/collections/${this.id}`;
|
||||
if (!this.name) return `/collection/untitled-${this.urlId}`;
|
||||
|
||||
return `/collection/${slugify(this.name)}-${this.urlId}`;
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -223,6 +225,17 @@ Collection.addHook("afterCreate", (model: Collection, options) => {
|
||||
|
||||
// Class methods
|
||||
|
||||
Collection.findByPk = async function (id, options = {}) {
|
||||
if (isUUID(id)) {
|
||||
return this.findOne({ where: { id }, ...options });
|
||||
} else if (id.match(SLUG_URL_REGEX)) {
|
||||
return this.findOne({
|
||||
where: { urlId: id.match(SLUG_URL_REGEX)[1] },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// get all the membership relationshps a user could have with the collection
|
||||
Collection.membershipUserIds = async (collectionId: string) => {
|
||||
const collection = await Collection.scope("withAllMemberships").findByPk(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import randomstring from "randomstring";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Collection, Document } from "../models";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
buildDocument,
|
||||
} from "../test/factories";
|
||||
import { flushdb, seed } from "../test/support";
|
||||
import slugify from "../utils/slugify";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
beforeEach(jest.resetAllMocks);
|
||||
@@ -16,7 +18,7 @@ beforeEach(jest.resetAllMocks);
|
||||
describe("#url", () => {
|
||||
test("should return correct url for the collection", () => {
|
||||
const collection = new Collection({ id: "1234" });
|
||||
expect(collection.url).toBe("/collections/1234");
|
||||
expect(collection.url).toBe(`/collection/untitled-${collection.urlId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -416,3 +418,53 @@ describe("#membershipUserIds", () => {
|
||||
expect(membershipUserIds.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#findByPk", () => {
|
||||
test("should return collection with collection Id", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id);
|
||||
|
||||
expect(response.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
test("should return collection when urlId is present", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = `${slugify(collection.name)}-${collection.urlId}`;
|
||||
|
||||
const response = await Collection.findByPk(id);
|
||||
|
||||
expect(response.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
test("should return undefined when incorrect uuid type", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id + "-incorrect");
|
||||
|
||||
expect(response).toBe(undefined);
|
||||
});
|
||||
|
||||
test("should return undefined when incorrect urlId length", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = `${slugify(collection.name)}-${collection.urlId}incorrect`;
|
||||
|
||||
const response = await Collection.findByPk(id);
|
||||
|
||||
expect(response).toBe(undefined);
|
||||
});
|
||||
|
||||
test("should return null when no collection is found with uuid", async () => {
|
||||
const response = await Collection.findByPk(
|
||||
"a9e71a81-7342-4ea3-9889-9b9cc8f667da"
|
||||
);
|
||||
|
||||
expect(response).toBe(null);
|
||||
});
|
||||
|
||||
test("should return null when no collection is found with urlId", async () => {
|
||||
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
|
||||
|
||||
const response = await Collection.findByPk(id);
|
||||
|
||||
expect(response).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import MarkdownSerializer from "slate-md-serializer";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { MAX_TITLE_LENGTH } from "../../shared/constants";
|
||||
import parseTitle from "../../shared/utils/parseTitle";
|
||||
import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers";
|
||||
import unescape from "../../shared/utils/unescape";
|
||||
import { Collection, User } from "../models";
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
@@ -14,7 +15,6 @@ import slugify from "../utils/slugify";
|
||||
import Revision from "./Revision";
|
||||
|
||||
const Op = Sequelize.Op;
|
||||
const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;
|
||||
const serializer = new MarkdownSerializer();
|
||||
|
||||
export const DOCUMENT_VERSION = 2;
|
||||
@@ -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
|
||||
@@ -216,10 +217,10 @@ Document.findByPk = async function (id, options = {}) {
|
||||
where: { id },
|
||||
...options,
|
||||
});
|
||||
} else if (id.match(URL_REGEX)) {
|
||||
} else if (id.match(SLUG_URL_REGEX)) {
|
||||
return scope.findOne({
|
||||
where: {
|
||||
urlId: id.match(URL_REGEX)[1],
|
||||
urlId: id.match(SLUG_URL_REGEX)[1],
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -637,7 +638,7 @@ Document.prototype.unarchive = async function (userId: string) {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!parent) this.parentDocumentId = undefined;
|
||||
if (!parent) this.parentDocumentId = null;
|
||||
}
|
||||
|
||||
if (!this.template) {
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
buildTeam,
|
||||
buildUser,
|
||||
} from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
import { flushdb, seed } from "../test/support";
|
||||
import slugify from "../utils/slugify";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
beforeEach(jest.resetAllMocks);
|
||||
@@ -307,3 +308,14 @@ describe("#delete", () => {
|
||||
expect(document.deletedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#findByPk", () => {
|
||||
test("should return document when urlId is correct", async () => {
|
||||
const { document } = await seed();
|
||||
const id = `${slugify(document.title)}-${document.urlId}`;
|
||||
|
||||
const response = await Document.findByPk(id);
|
||||
|
||||
expect(response.id).toBe(document.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,11 @@ const Team = sequelize.define(
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
multiplayerEditor: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
paranoid: true,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// @flow
|
||||
import crypto from "crypto";
|
||||
import addMinutes from "date-fns/add_minutes";
|
||||
import subMinutes from "date-fns/sub_minutes";
|
||||
import { addMinutes, subMinutes } from "date-fns";
|
||||
import JWT from "jsonwebtoken";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
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,
|
||||
@@ -75,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];
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import subMilliseconds from "date-fns/sub_milliseconds";
|
||||
import { subMilliseconds } from "date-fns";
|
||||
import { USER_PRESENCE_INTERVAL } from "../../shared/constants";
|
||||
import { User } from "../models";
|
||||
import { DataTypes, Op, sequelize } from "../sequelize";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ allow(User, "move", Collection, (user, collection) => {
|
||||
throw new AdminRequiredError();
|
||||
});
|
||||
|
||||
allow(User, ["read", "export"], Collection, (user, collection) => {
|
||||
allow(User, "read", Collection, (user, collection) => {
|
||||
if (!collection || user.teamId !== collection.teamId) return false;
|
||||
|
||||
if (!collection.permission) {
|
||||
@@ -47,7 +47,7 @@ allow(User, ["read", "export"], Collection, (user, collection) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
allow(User, "share", Collection, (user, collection) => {
|
||||
allow(User, ["share", "export"], Collection, (user, collection) => {
|
||||
if (user.isViewer) return false;
|
||||
if (!collection || user.teamId !== collection.teamId) return false;
|
||||
if (!collection.sharing) return false;
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("read permission", () => {
|
||||
});
|
||||
const abilities = serialize(user, collection);
|
||||
expect(abilities.read).toEqual(true);
|
||||
expect(abilities.export).toEqual(true);
|
||||
expect(abilities.export).toEqual(false);
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.share).toEqual(false);
|
||||
});
|
||||
|
||||
@@ -149,6 +149,7 @@ allow(User, "unarchive", Document, (user, document) => {
|
||||
if (cannot(user, "update", document.collection)) return false;
|
||||
|
||||
if (!document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,6 +24,7 @@ export default function present(collection: Collection) {
|
||||
const data = {
|
||||
id: collection.id,
|
||||
url: collection.url,
|
||||
urlId: collection.urlId,
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
sort: collection.sort,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+33
-5
@@ -6,14 +6,17 @@ import Koa from "koa";
|
||||
import Router from "koa-router";
|
||||
import sendfile from "koa-sendfile";
|
||||
import serve from "koa-static";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { languages } from "../shared/i18n";
|
||||
import env from "./env";
|
||||
import apexRedirect from "./middlewares/apexRedirect";
|
||||
import Share from "./models/Share";
|
||||
import { opensearchResponse } from "./utils/opensearch";
|
||||
import prefetchTags from "./utils/prefetchTags";
|
||||
import { robotsResponse } from "./utils/robots";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const isTest = process.env.NODE_ENV === "test";
|
||||
const koa = new Koa();
|
||||
const router = new Router();
|
||||
const readFile = util.promisify(fs.readFile);
|
||||
@@ -22,6 +25,9 @@ const readIndexFile = async (ctx) => {
|
||||
if (isProduction) {
|
||||
return readFile(path.join(__dirname, "../app/index.html"));
|
||||
}
|
||||
if (isTest) {
|
||||
return readFile(path.join(__dirname, "/static/index.html"));
|
||||
}
|
||||
|
||||
const middleware = ctx.devMiddleware;
|
||||
await new Promise((resolve) => middleware.waitUntilValid(resolve));
|
||||
@@ -39,7 +45,7 @@ const readIndexFile = async (ctx) => {
|
||||
});
|
||||
};
|
||||
|
||||
const renderApp = async (ctx, next) => {
|
||||
const renderApp = async (ctx, next, title = "Outline") => {
|
||||
if (ctx.request.path === "/realtime/") {
|
||||
return next();
|
||||
}
|
||||
@@ -51,10 +57,34 @@ const renderApp = async (ctx, next) => {
|
||||
ctx.body = page
|
||||
.toString()
|
||||
.replace(/\/\/inject-env\/\//g, environment)
|
||||
.replace(/\/\/inject-title\/\//g, title)
|
||||
.replace(/\/\/inject-prefetch\/\//g, prefetchTags)
|
||||
.replace(/\/\/inject-slack-app-id\/\//g, process.env.SLACK_APP_ID || "");
|
||||
};
|
||||
|
||||
const renderShare = async (ctx, next) => {
|
||||
const { shareId } = ctx.params;
|
||||
|
||||
// Find the share record if publicly published so that the document title
|
||||
// can be be returned in the server-rendered HTML. This allows it to appear in
|
||||
// unfurls with more reliablity
|
||||
let share;
|
||||
|
||||
if (isUUID(shareId)) {
|
||||
share = await Share.findOne({
|
||||
where: {
|
||||
id: shareId,
|
||||
published: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Allow shares to be embedded in iframes on other websites
|
||||
ctx.remove("X-Frame-Options");
|
||||
|
||||
return renderApp(ctx, next, share ? share.document.title : undefined);
|
||||
};
|
||||
|
||||
// serve static assets
|
||||
koa.use(
|
||||
serve(path.resolve(__dirname, "../../public"), {
|
||||
@@ -105,10 +135,8 @@ router.get("/opensearch.xml", (ctx) => {
|
||||
ctx.body = opensearchResponse();
|
||||
});
|
||||
|
||||
router.get("/share/*", (ctx, next) => {
|
||||
ctx.remove("X-Frame-Options");
|
||||
return renderApp(ctx, next);
|
||||
});
|
||||
router.get("/share/:shareId", renderShare);
|
||||
router.get("/share/:shareId/*", renderShare);
|
||||
|
||||
// catch all for application
|
||||
router.get("*", renderApp);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user