Compare commits

...

82 Commits

Author SHA1 Message Date
Tom Moor 35a4dd19e6 lint 2021-06-13 23:17:57 -07:00
Tom Moor 750d9ab4c6 Merge main 2021-06-13 22:11:29 -07:00
Tom Moor cdf0df0faa Bump dep styled-components 2021-06-13 18:26:25 -07:00
Tom Moor 48f54b5aa2 fix: Unauthorized request to views.list from shared documents 2021-06-13 18:24:02 -07:00
Tom Moor 2ca57fc7cf fix: 3 locations with return undefined (not compatible with React 17) 2021-06-13 17:47:17 -07:00
Tom Moor 470920e2c3 feat: Allow templates from any collection to be used
fix: Hover state of context menu items with icons
2021-06-13 17:43:50 -07:00
Tom Moor beee8ebee7 fix: Sidebar flash when moving between collection/document due to mobx-react upgrade 2021-06-13 17:22:35 -07:00
Tom Moor 9f05c9bd43 chore: Upgrade React to v17 (#2045)
* chore: Upgrade React v17

* chore: Upgrade additional deps to reduce warnings

* fix: Restore react-table dep

* Bump react-avatar-editor, mobx-react

* Remove unmaintained @rehooks/window-scroll-position dep

* Bump react-waypoint dep for React 17 support

* fix: Syntax error in autotrack chunk name comment
2021-06-13 15:23:53 -07:00
Tom Moor 65be808556 fix: Cause of sporadic test failures in CI, promise not returned for flushdb 2021-06-13 14:52:24 -07:00
Tom Moor 89f8df619c fix: Remove export permission for read-only users (#2220) 2021-06-13 14:41:29 -07:00
Tom Moor 756ec92cdb fix: Link copied to clipboard takes dark mode styles (#2218)
Upgrade copy-to-clipboard
closes #2207
2021-06-12 15:44:58 -07:00
Dave a8e2e349e9 fix: change metaDisplay key to Alt for "Table of contents" (#2187)
* change `metaDisplay` key to Alt for "Table of contents"

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-12 15:37:35 -07:00
Tom Moor 25f745e7e5 fix: Text alignment in templates menu
closes #2204
2021-06-12 13:31:22 -07:00
Nainterceptor 07b1811993 feat: Use SMTP_SECURE environment variable to force secure parameter of smtp configuration (#2214) 2021-06-12 11:01:48 -07:00
Saumya Pandey d71f0ae6bd fix: Two restore options when an archived document is deleted (#2194)
* Merge two menu items

* Add deletedAt guard condition in document unarchive policy

* Make the parentDocumentId null

* Update test
2021-06-10 22:52:32 -07:00
Tom Moor f58032d305 fix: Flash of sidebar when first loading Document chunk 2021-06-09 18:01:35 -07:00
Saumya Pandey 6beb6febc4 fix: Use friendly urls for collections (#2162)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-09 17:48:48 -07:00
Saumya Pandey a6d4d4ea36 fix: Add Portugese, Brazil to language options (#2164)
* Add Portugese, Brazil to language options

* Upgrade date-fns package

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-09 17:42:14 -07:00
Tom Moor a99f6bed42 feat: Return publicly shared document title in SSR HTML (#2191)
* feat: Return publicly shared document title in SSR HTML
closes #2146

* tests
2021-06-09 17:41:39 -07:00
Tom Moor 4cd61db1ea fix: Move views loading to avoid duplicate request 2021-06-08 21:13:56 -07:00
Tom Moor 0db7bb7f3e Bump editor
closes #2156
closes #2067
2021-06-07 20:32:41 -07:00
Tom Moor d8ca9c6111 fix: Server error if non-array passed to users.invite 2021-06-07 20:28:28 -07:00
Dave 4a8d357084 style: add option background for InputSelect (#2188) 2021-06-07 18:34:01 -07:00
Yao Wang e0fb76cb63 documentation: Instructions for local development (#2180)
* Fix the instruction for local development

* update readme for Slack OAuth in local development

* Fix the callback URL setting instruction
2021-06-07 18:11:45 -07:00
Saumya Pandey ffed38bf71 fix: Prevent API request for views (#2193) 2021-06-07 18:10:54 -07:00
Tom Moor b4c08a027b fix: Remove hover state css on sidebar items on mobile
closes #2043
2021-06-06 19:56:31 -07:00
Tom Moor d40e60675d Merge develop 2020-12-08 20:47:04 -08:00
Tom Moor 76169c1bf2 guard 2020-11-25 08:52:47 -08:00
Tom Moor 8ed030e2ec refactor 2020-11-20 00:05:25 -08:00
Tom Moor a55163fb00 working refactor, but more to do 2020-11-19 00:11:58 -08:00
Tom Moor b05a36d450 wip: Refactoring editor 2020-11-18 21:33:02 -08:00
Tom Moor dde6c3e443 Relax documents.update endpoint
fix: documents.update should not trigger event if nothing changes
2020-11-18 19:48:57 -08:00
Tom Moor e861884b4e fix: Cannot publish/edit title 2020-11-17 23:16:45 -08:00
Tom Moor 4ca2d3776e fix: url not returned for new docs 2020-11-17 22:49:55 -08:00
Tom Moor 7c0ddf7efb chore: Track whether remote process update in origin 2020-11-17 21:33:08 -08:00
Tom Moor 6842ea4a35 feat: Animated reconnecting state 2020-11-17 20:52:01 -08:00
Tom Moor 7e0ebc6b4e chore: Move back to y-prosemirror trunk 2020-11-17 20:26:32 -08:00
Tom Moor c0a322bc20 snapshots 2020-11-16 22:08:22 -08:00
Tom Moor 769b0225e2 lint 2020-11-16 21:52:34 -08:00
Tom Moor 9bcf5b0292 fix: race, avoid update event if no text changed 2020-11-16 21:25:40 -08:00
Tom Moor 3b4aa02c67 fix: various connection issues 2020-11-16 21:25:16 -08:00
Tom Moor 375d658231 skip backlink title rewriting for now 2020-11-16 21:25:07 -08:00
Tom Moor 70ea77ce01 hook up awareness to UI
fix header disappearing
2020-11-15 20:06:41 -08:00
Tom Moor cea1d808d1 Bump deps 2020-11-15 12:24:01 -08:00
Tom Moor bea8b85cf9 Merge develop 2020-11-15 11:23:04 -08:00
Tom Moor abeccb8a4c stash 2020-11-08 15:58:00 -08:00
Tom Moor c8d3d26044 Merge branch 'develop' of github.com:outline/outline into yjs 2020-11-05 23:05:16 -08:00
Tom Moor ec5a7d79f5 flow 2020-11-05 18:41:22 -08:00
Tom Moor 30d31b35ac fix: Issue with inflating clientIds in pud 2020-11-04 19:12:55 -08:00
Tom Moor 8abf2436dd wip 2020-11-01 19:52:34 -08:00
Tom Moor 4df75bda7b Remove unneeded applyUpdate 2020-11-01 15:52:11 -08:00
Tom Moor 220546c40a fix: Multiplayer cursors in headings 2020-11-01 13:06:13 -08:00
Tom Moor b96ffe59db Merge develop 2020-11-01 13:02:58 -08:00
Tom Moor 2676a7e8cf events 2020-10-26 23:25:19 -07:00
Tom Moor 5e9e4fb028 Merge branch 'develop' into yjs 2020-10-26 21:39:57 -07:00
Tom Moor 551b1620e0 fix: Flag without realtime editing 2020-10-26 19:27:49 -07:00
Tom Moor b8569ed8de remove flag sidebar item for now 2020-10-25 19:37:13 -07:00
Tom Moor 8d1a707dd0 basic offline messaging 2020-10-25 19:36:10 -07:00
Tom Moor 9877cf1f4e install 2020-10-25 19:13:29 -07:00
Tom Moor 50fbcd8d85 Merge develop 2020-10-25 19:11:21 -07:00
Tom Moor 359d228771 lint 2020-10-25 19:10:31 -07:00
Tom Moor 0347620c75 fix: Update collaboratorIds 2020-10-25 14:42:28 -07:00
Tom Moor cb362511a5 Load from db 2020-10-25 10:40:39 -07:00
Tom Moor 700db463fc fix: Awareness state not available if server dies and restarts 2020-10-24 19:46:08 -07:00
Tom Moor a28dfa77ee fix: Race condition when setting up socket listeners 2020-10-24 19:45:51 -07:00
Tom Moor d8bc6515dd race 2020-10-23 10:06:44 -07:00
Tom Moor 0776b78e25 wip 2020-10-19 07:33:43 -07:00
Tom Moor 4256e7ec87 flow 2020-10-18 22:32:04 -07:00
Tom Moor e723124f8f lint 2020-10-18 22:26:36 -07:00
Tom Moor c2fbd78622 flow-types 2020-10-18 22:16:42 -07:00
Tom Moor 17cbeab409 refactor 2020-10-18 21:36:24 -07:00
Tom Moor 37d456a0fb refactor 2020-10-18 15:37:50 -07:00
Tom Moor 48a0ba0dec refactor, resolve memory leak 2020-10-17 17:47:04 -07:00
Tom Moor f454467bf1 event filtering 2020-10-17 13:21:48 -07:00
Tom Moor f21f660543 stash 2020-10-16 16:19:29 -07:00
Tom Moor 50637bc7ce local cache, more brand-like colors 2020-10-15 23:03:05 -07:00
Tom Moor acb61d5e0c Match editing color to avatar and brand 2020-10-15 22:43:42 -07:00
Tom Moor 7dcbaa9c5c cursors 2020-10-15 22:10:30 -07:00
Tom Moor ae1761e517 wip 2020-10-15 20:24:44 -07:00
Tom Moor 7166378c32 Restore old functionality, put new functionality behind flag 2020-10-13 08:43:53 -07:00
Tom Moor 2719321430 refactoring 2020-10-11 23:06:15 -07:00
Tom Moor a7f2c7edb3 rough, but working 2020-10-11 20:54:31 -07:00
117 changed files with 3836 additions and 1113 deletions
+4
View File
@@ -11,6 +11,10 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
+2 -1
View File
@@ -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
+3 -1
View File
@@ -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 =
+2 -6
View File
@@ -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")`
+1
View File
@@ -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();
+3 -2
View File
@@ -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 && (
+6
View File
@@ -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",
+2 -6
View File
@@ -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()
),
});
+61
View File
@@ -245,6 +245,53 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
}
}
.ProseMirror {
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
transition: opacity 100ms ease-in-out;
}
}
}
}
`;
const EditorTooltip = ({ children, ...props }) => (
@@ -262,3 +309,17 @@ const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
));
// > .ProseMirror-yjs-cursor:first-child {
// margin-top: 16px;
// }
// p:first-child,
// h1:first-child,
// h2:first-child,
// h3:first-child,
// h4:first-child,
// h5:first-child,
// h6:first-child {
// margin-top: 16px;
// }
+4
View File
@@ -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};
+20
View File
@@ -0,0 +1,20 @@
// @flow
import * as React from "react";
type Props = {|
interval?: number,
|};
export default function LoadingEllipsis({ interval = 750 }: Props) {
const [step, setStep] = React.useState(0);
React.useEffect(() => {
const handle = setInterval(() => {
setStep((step) => (step === 3 ? 0 : step + 1));
}, interval);
return () => clearInterval(handle);
}, [interval]);
return ".".repeat(step);
}
+14 -13
View File
@@ -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`
+2 -2
View File
@@ -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,
});
+1 -1
View File
@@ -4,6 +4,6 @@ import useStores from "./useStores";
export default function useCurrentTeam() {
const { auth } = useStores();
invariant(auth.team, "team required");
invariant(auth.team, "Expected to be authenticated");
return auth.team;
}
+1 -1
View File
@@ -8,5 +8,5 @@ export default function useUserLocale() {
return undefined;
}
return auth.user.language.split("_")[0];
return auth.user.language;
}
+52
View File
@@ -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
View File
@@ -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");
+1 -6
View File
@@ -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,
},
{
+1 -1
View File
@@ -25,7 +25,7 @@ function NewDocumentMenu() {
const can = policies.abilities(team.id);
if (!can.createDocument) {
return;
return null;
}
if (singleCollection) {
+1 -1
View File
@@ -23,7 +23,7 @@ function NewTemplateMenu() {
const can = policies.abilities(team.id);
if (!can.createDocument) {
return;
return null;
}
return (
+32 -16
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -8,6 +8,7 @@ class Team extends BaseModel {
avatarUrl: string;
sharing: boolean;
documentEmbeds: boolean;
multiplayerEditor: boolean;
guestSignin: boolean;
subdomain: ?string;
domain: ?string;
+1
View File
@@ -8,6 +8,7 @@ class User extends BaseModel {
id: string;
name: string;
email: string;
color: string;
isAdmin: boolean;
isViewer: boolean;
lastActiveAt: string;
+55
View File
@@ -0,0 +1,55 @@
// @flow
import { keymap } from "prosemirror-keymap";
import { Extension } from "rich-markdown-editor";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "y-prosemirror";
import * as Y from "yjs";
export default class MultiplayerExtension extends Extension {
get name() {
return "multiplayer";
}
get plugins() {
const { user, provider, doc } = this.options;
const type = doc.get("prosemirror", Y.XmlFragment);
const assignUser = (tr) => {
const clientIds = Array.from(doc.store.clients.keys());
if (
tr.local &&
tr.changed.size > 0 &&
!clientIds.includes(doc.clientID)
) {
const permanentUserData = new Y.PermanentUserData(doc);
permanentUserData.setUserMapping(doc, doc.clientID, user.id);
doc.off("afterTransaction", assignUser);
}
};
provider.awareness.setLocalStateField("user", {
color: user.color,
name: user.name,
id: user.id,
});
doc.on("afterTransaction", assignUser);
return [
ySyncPlugin(type),
yCursorPlugin(provider.awareness),
yUndoPlugin(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
];
}
}
+356
View File
@@ -0,0 +1,356 @@
// Based on example implementation, modified to work with existing sockets
// https://github.com/yjs/y-websocket/blob/master/src/y-websocket.js
// @flow
import * as bc from "lib0/broadcastchannel.js";
import * as decoding from "lib0/decoding.js";
import * as encoding from "lib0/encoding.js";
import * as mutex from "lib0/mutex.js";
import { Observable } from "lib0/observable.js";
import { Socket } from "socket.io-client";
import * as awarenessProtocol from "y-protocols/awareness.js";
import * as syncProtocol from "y-protocols/sync.js";
import * as Y from "yjs";
import {
MESSAGE_SYNC,
MESSAGE_AWARENESS,
MESSAGE_QUERY_AWARENESS,
} from "shared/constants";
const readMessage = (
provider: WebsocketProvider,
buff: Uint8Array,
emitSynced: boolean
): encoding.Encoder => {
const decoder = decoding.createDecoder(buff);
const encoder = encoding.createEncoder();
const messageType = decoding.readVarUint(decoder);
switch (messageType) {
case MESSAGE_SYNC: {
encoding.writeVarUint(encoder, MESSAGE_SYNC);
const syncMessageType = syncProtocol.readSyncMessage(
decoder,
encoder,
provider.doc,
provider
);
if (
emitSynced &&
syncMessageType === syncProtocol.messageYjsSyncStep2 &&
!provider.synced
) {
provider.synced = true;
}
break;
}
case MESSAGE_QUERY_AWARENESS:
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(
provider.awareness,
Array.from(provider.awareness.getStates().keys())
)
);
break;
case MESSAGE_AWARENESS:
awarenessProtocol.applyAwarenessUpdate(
provider.awareness,
decoding.readVarUint8Array(decoder),
provider
);
break;
default:
console.error("Unable to compute message");
return encoder;
}
return encoder;
};
const broadcastMessage = (provider: WebsocketProvider, buff: ArrayBuffer) => {
if (provider.wsconnected) {
provider.wsPublish(buff);
}
if (provider.bcconnected) {
provider.mux(() => {
bc.publish(provider.documentId, buff);
});
}
};
/**
* Websocket Provider for Yjs. Syncs the shared document using socket.io socket
*/
export class WebsocketProvider extends Observable {
constructor(
socket: Socket,
documentId: string,
userId: string,
doc: Y.Doc,
{
awareness = new awarenessProtocol.Awareness(doc),
resyncInterval = 0,
}: {
awareness: awarenessProtocol.Awareness,
resyncInterval: number,
} = {}
) {
super();
this.socket = socket;
this.bcChannel = documentId;
this.documentId = documentId;
this.userId = userId;
this.doc = doc;
this.awareness = awareness;
this.wsconnected = false;
this.bcconnected = false;
this.shouldConnect = true;
this.mux = mutex.createMutex();
this._synced = false;
this._resyncInterval = 0;
if (resyncInterval > 0) {
this._resyncInterval = setInterval(() => {
if (this.ws) {
// resend sync step 1
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.writeSyncStep1(encoder, doc);
this.wsPublish(encoding.toUint8Array(encoder));
}
}, resyncInterval);
}
this.doc.on("update", this._updateHandler);
window.addEventListener("beforeunload", this._unloadHandler);
awareness.on("update", this._awarenessUpdateHandler);
this.connect();
}
_unloadHandler = () => {
awarenessProtocol.removeAwarenessStates(
this.awareness,
[this.doc.clientID],
"window unload"
);
};
_bcSubscriber = (data: ArrayBuffer) => {
this.mux(() => {
const encoder = readMessage(this, new Uint8Array(data), false);
if (encoding.length(encoder) > 1) {
bc.publish(this.bcChannel, encoding.toUint8Array(encoder));
}
});
};
/**
* Listens to Yjs updates and sends them to remote peers (ws and broadcastchannel)
*/
_updateHandler = (update: Uint8Array, origin: any) => {
if (origin !== this || origin === null) {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.writeUpdate(encoder, update);
broadcastMessage(this, encoding.toUint8Array(encoder));
}
};
_awarenessUpdateHandler = ({ added, updated, removed }: any, origin: any) => {
const changedClients = added.concat(updated).concat(removed);
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients)
);
broadcastMessage(this, encoding.toUint8Array(encoder));
};
get synced() {
return this._synced;
}
set synced(state: boolean) {
if (this._synced !== state) {
this._synced = state;
this.emit("sync", [state]);
}
}
destroy() {
if (this._resyncInterval !== 0) {
clearInterval(this._resyncInterval);
}
this.disconnect();
this.awareness.off("update", this._awarenessUpdateHandler);
this.doc.off("update", this._updateHandler);
this.awareness.destroy();
window.removeEventListener("beforeunload", this._unloadHandler);
super.destroy();
}
connectBc() {
if (!this.bcconnected) {
bc.subscribe(this.bcChannel, this._bcSubscriber);
this.bcconnected = true;
}
// send sync step1 to bc
this.mux(() => {
// write sync step 1
const encoderSync = encoding.createEncoder();
encoding.writeVarUint(encoderSync, MESSAGE_SYNC);
syncProtocol.writeSyncStep1(encoderSync, this.doc);
bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync));
// broadcast local state
const encoderState = encoding.createEncoder();
encoding.writeVarUint(encoderState, MESSAGE_SYNC);
syncProtocol.writeSyncStep2(encoderState, this.doc);
bc.publish(this.bcChannel, encoding.toUint8Array(encoderState));
// write queryAwareness
const encoderAwarenessQuery = encoding.createEncoder();
encoding.writeVarUint(encoderAwarenessQuery, MESSAGE_QUERY_AWARENESS);
bc.publish(this.bcChannel, encoding.toUint8Array(encoderAwarenessQuery));
// broadcast local awareness state
const encoderAwarenessState = encoding.createEncoder();
encoding.writeVarUint(encoderAwarenessState, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoderAwarenessState,
awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
this.doc.clientID,
])
);
bc.publish(this.bcChannel, encoding.toUint8Array(encoderAwarenessState));
});
}
disconnectBc() {
// broadcast message with local awareness state set to null (indicating disconnect)
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(
this.awareness,
[this.doc.clientID],
new Map()
)
);
broadcastMessage(this, encoding.toUint8Array(encoder));
if (this.bcconnected) {
bc.unsubscribe(this.bcChannel, this._bcSubscriber);
this.bcconnected = false;
}
}
wsPublish(data: ArrayBuffer) {
if (!data) return;
this.socket.binary(true).emit("sync", {
documentId: this.documentId,
userId: this.userId,
data,
});
}
_wsMessageHandler = (event: {
documentId: string,
userId: string,
data: ArrayBuffer,
}) => {
if (event.documentId === this.documentId) {
const encoder = readMessage(this, new Uint8Array(event.data), true);
if (encoding.length(encoder) > 1) {
this.wsPublish(encoding.toUint8Array(encoder));
}
}
};
_wsCloseHandler = () => {
awarenessProtocol.removeAwarenessStates(
this.awareness,
Array.from(this.awareness.getStates().keys()),
this
);
this.emit("status", [
{
status: "disconnected",
},
]);
};
_wsJoinHandler = (event: { documentId: string, userId: string }) => {
if (event.userId !== this.userId || event.documentId !== this.documentId) {
return;
}
console.log("user.join");
this.awareness.setLocalState({});
this.emit("status", [
{
status: "connected",
},
]);
console.log("writing sync step 1");
// always send sync step 1 when connected
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.writeSyncStep1(encoder, this.doc);
this.wsPublish(encoding.toUint8Array(encoder));
// broadcast local awareness state
if (this.awareness.getLocalState() !== null) {
console.log("broadcast awareness state");
const encoderAwarenessState = encoding.createEncoder();
encoding.writeVarUint(encoderAwarenessState, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoderAwarenessState,
awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
this.doc.clientID,
])
);
this.wsPublish(encoding.toUint8Array(encoderAwarenessState));
}
};
connectWs() {
this.socket.on("document.sync", this._wsMessageHandler);
this.socket.on("disconnect", this._wsCloseHandler);
this.socket.on("user.join", this._wsJoinHandler);
}
disconnectWs() {
this.socket.off("document.sync", this._wsMessageHandler);
this.socket.off("disconnect", this._wsCloseHandler);
this.socket.off("user.join", this._wsJoinHandler);
}
disconnect() {
this.shouldConnect = false;
this.disconnectWs();
this.disconnectBc();
}
connect() {
this.shouldConnect = true;
if (!this.wsconnected) {
this.wsconnected = true;
this.connectWs();
this.connectBc();
}
}
}
+40 -37
View File
@@ -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
View File
@@ -12,10 +12,8 @@ const Authenticated = React.lazy(() =>
const AuthenticatedRoutes = React.lazy(() =>
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const SharedDocument = React.lazy(() =>
import(/* webpackChunkName: "shared-document" */ "scenes/Document/Shared")
);
const Login = React.lazy(() =>
import(/* webpackChunkName: "login" */ "scenes/Login")
@@ -37,11 +35,11 @@ export default function Routes() {
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Route exact path="/share/:shareId" component={SharedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={KeyedDocument}
component={SharedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
+2
View File
@@ -2,6 +2,7 @@
import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import Details from "scenes/Settings/Details";
import Features from "scenes/Settings/Features";
import Groups from "scenes/Settings/Groups";
import ImportExport from "scenes/Settings/ImportExport";
import Notifications from "scenes/Settings/Notifications";
@@ -19,6 +20,7 @@ export default function SettingsRoutes() {
<Switch>
<Route exact path="/settings" component={Profile} />
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/features" component={Features} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/members" component={People} />
<Route exact path="/settings/groups" component={Groups} />
+46 -25
View File
@@ -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("AZ")}
</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}
-25
View File
@@ -1,25 +0,0 @@
// @flow
import { inject } from "mobx-react";
import * as React from "react";
import DataLoader from "./components/DataLoader";
class KeyedDocument extends React.Component<*> {
componentWillUnmount() {
this.props.ui.clearActiveDocument();
}
render() {
const { documentSlug, revisionId } = this.props.match.params;
// the urlId portion of the url does not include the slugified title
// we only want to force a re-mount of the document component when the
// document changes, not when the title does so only this portion is used
// for the key.
const urlParts = documentSlug ? documentSlug.split("-") : [];
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
return <DataLoader key={[urlId, revisionId].join("/")} {...this.props} />;
}
}
export default inject("ui")(KeyedDocument);
+61
View File
@@ -0,0 +1,61 @@
// @flow
import * as React from "react";
import { type Match } from "react-router-dom";
import { useTheme } from "styled-components";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import useStores from "../../hooks/useStores";
import Document from "./components/Document";
import Loading from "./components/Loading";
import { type LocationWithState } from "types";
import { OfflineError } from "utils/errors";
type Props = {|
match: Match,
location: LocationWithState,
|};
export default function SharedEditor(props: Props) {
const theme = useTheme();
const [response, setResponse] = React.useState();
const [error, setError] = React.useState<?Error>();
const { documents } = useStores();
const { shareId, documentSlug } = props.match.params;
// ensure the wider page color always matches the theme
React.useEffect(() => {
window.document.body.style.background = theme.background;
}, [theme]);
React.useEffect(() => {
async function fetchData() {
try {
const response = await documents.fetch(documentSlug, {
shareId,
});
setResponse(response);
} catch (err) {
setError(err);
}
}
fetchData();
}, [documents, documentSlug, shareId]);
if (error) {
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
}
if (!response) {
return <Loading location={props.location} />;
}
return (
<Document
document={response.document}
sharedTree={response.sharedTree}
location={props.location}
shareId={shareId}
readOnly
/>
);
}
+1 -1
View File
@@ -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;
+29 -24
View File
@@ -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>
);
}
}
+62 -21
View File
@@ -6,9 +6,10 @@ import { InputIcon } from "outline-icons";
import * as React from "react";
import keydown from "react-keydown";
import { Prompt, Route, withRouter } from "react-router-dom";
import type { RouterHistory, Match } from "react-router-dom";
import type { RouterHistory } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Y from "yjs";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
@@ -17,6 +18,7 @@ import DocumentMove from "scenes/DocumentMove";
import Branding from "components/Branding";
import ErrorBoundary from "components/ErrorBoundary";
import Flex from "components/Flex";
import LoadingEllipsis from "components/LoadingEllipsis";
import LoadingIndicator from "components/LoadingIndicator";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import Modal from "components/Modal";
@@ -31,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 youre back
online.{" "}
{multiplayer.isReconnecting && (
<>
Trying to reconnect
<LoadingEllipsis />
</>
)}
</Notice>
)}
<React.Suspense fallback={<LoadingPlaceholder />}>
<Flex auto={!readOnly}>
{showContents && <Contents headings={headings} />}
<Editor
id={document.id}
innerRef={this.editor}
canShowHoverPreviews={!isShare}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
@@ -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}>
+28 -10
View File
@@ -5,6 +5,7 @@ import * as React from "react";
import Textarea from "react-autosize-textarea";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Y from "yjs";
import { MAX_TITLE_LENGTH } from "shared/constants";
import { light } from "shared/styles/theme";
import parseTitle from "shared/utils/parseTitle";
@@ -15,6 +16,8 @@ import Editor, { type Props as EditorProps } from "components/Editor";
import Flex from "components/Flex";
import HoverPreview from "components/HoverPreview";
import Star, { AnimatedStar } from "components/Star";
import MultiplayerEditor from "./MultiplayerEditor";
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
import { isModKey } from "utils/keyboard";
import { documentHistoryUrl } from "utils/routeHelpers";
@@ -24,9 +27,18 @@ type Props = {|
title: string,
document: Document,
isDraft: boolean,
shareId: ?string,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
canShowHoverPreviews?: boolean,
readOnly?: boolean,
onSave: ({ publish?: boolean, done?: boolean, autosave?: boolean }) => mixed,
innerRef: { current: any },
multiplayer: {
isConnected: boolean,
isReconnecting: boolean,
isRemoteSynced: boolean,
provider: ?WebsocketProvider,
doc: Y.Doc,
},
shareId: ?string,
children: React.Node,
|};
@@ -97,15 +109,18 @@ class DocumentEditor extends React.Component<Props> {
title,
onChangeTitle,
isDraft,
shareId,
canShowHoverPreviews,
readOnly,
innerRef,
multiplayer,
shareId,
children,
...rest
} = this.props;
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
const normalizedTitle =
!title && readOnly ? document.titleWithDefault : title;
@@ -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}
+1 -1
View File
@@ -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,
});
}
+64 -1
View File
@@ -1,3 +1,66 @@
// @flow
import * as React from "react";
import { type Match } from "react-router-dom";
import DataLoader from "./components/DataLoader";
export default DataLoader;
import Document from "./components/Document";
import SocketPresence from "./components/SocketPresence";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import { type LocationWithState } from "types";
type Props = {|
location: LocationWithState,
match: Match,
|};
export default function DocumentScene(props: Props) {
const { ui } = useStores();
const user = useCurrentUser();
const team = useCurrentTeam();
React.useEffect(() => {
return () => ui.clearActiveDocument();
}, [ui]);
const { documentSlug, revisionId } = props.match.params;
// the urlId portion of the url does not include the slugified title
// we only want to force a re-mount of the document component when the
// document changes, not when the title does so only this portion is used
// for the key.
const urlParts = documentSlug ? documentSlug.split("-") : [];
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
const key = [urlId, revisionId].join("/");
const isMultiplayer = team.multiplayerEditor;
return (
<DataLoader key={key} match={props.match}>
{({ document, ...rest }) => {
const isActive =
!document.isArchived && !document.isDeleted && !revisionId;
if (isActive) {
return (
<SocketPresence
documentId={document.id}
userId={user.id}
isMultiplayer={isMultiplayer}
>
{(multiplayer) => (
<Document
document={document}
match={props.match}
multiplayer={multiplayer}
{...rest}
/>
)}
</SocketPresence>
);
}
return <Document document={document} match={props.match} {...rest} />;
}}
</DataLoader>
);
}
+4 -3
View File
@@ -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
View File
@@ -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("Couldnt 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("Couldnt 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);
+1 -1
View File
@@ -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"),
+70
View File
@@ -0,0 +1,70 @@
// @flow
import { debounce } from "lodash";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import CenteredContent from "components/CenteredContent";
import Checkbox from "components/Checkbox";
import HelpText from "components/HelpText";
import PageTitle from "components/PageTitle";
type Props = {
auth: AuthStore,
ui: UiStore,
};
@observer
class Features extends React.Component<Props> {
form: ?HTMLFormElement;
@observable multiplayerEditor: boolean;
componentDidMount() {
const { auth } = this.props;
if (auth.team) {
this.multiplayerEditor = auth.team.multiplayerEditor;
}
}
handleChange = async (ev: SyntheticInputEvent<*>) => {
switch (ev.target.name) {
case "multiplayerEditor":
this.multiplayerEditor = ev.target.checked;
break;
default:
}
await this.props.auth.updateTeam({
multiplayerEditor: this.multiplayerEditor,
});
this.showSuccessMessage();
};
showSuccessMessage = debounce(() => {
this.props.ui.showToast("Settings saved");
}, 500);
render() {
return (
<CenteredContent>
<PageTitle title="Labs" />
<h1>Labs</h1>
<HelpText>
Enable experimental features that are still under development.
</HelpText>
<Checkbox
label="Multiplayer editor"
name="multiplayerEditor"
checked={this.multiplayerEditor}
onChange={this.handleChange}
note="Allow multiple team members to edit documents at the same time"
/>
</CenteredContent>
);
}
}
export default inject("auth", "ui")(Features);
+2 -2
View File
@@ -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>
+2 -1
View File
@@ -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;
+29 -1
View File
@@ -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);
+26
View File
@@ -34,6 +34,32 @@ export default class PresenceStore {
this.data.set(documentId, existing);
}
@action updateFromAwareness(documentId: string, awareness: any) {
const existing = this.data.get(documentId) || new Map();
const clients = Array.from(awareness.states.values());
const userIds = clients.map((client) => client.user && client.user.id);
existing.forEach((value, key) => {
if (!userIds.includes(key)) {
existing.delete(key);
}
});
clients.forEach((client) => {
if (!client.user) {
return;
}
const userId = client.user.id;
existing.set(userId, {
isEditing: !!client.cursor,
userId,
});
});
this.data.set(documentId, existing);
}
// called when a user presence message is received user.presence websocket
// message.
// While in edit mode a message is sent every USER_PRESENCE_INTERVAL, if
+1 -1
View File
@@ -603,7 +603,7 @@ export default class DocumentsStore extends BaseStore<Document> {
async update(params: {
id: string,
title: string,
text: string,
text?: string,
lastRevision: number,
}) {
const document = await super.update(params);
-6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+377
View File
@@ -0,0 +1,377 @@
// flow-typed signature: 97da878aea98698d6c06f8a696bb62af
// flow-typed version: <<STUB>>/lib0_v0.2.34/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'lib0'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
// @flow
declare module "lib0" {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module "lib0/array" {
declare module.exports: any;
}
declare module "lib0/bin/gendocs" {
declare module.exports: any;
}
declare module "lib0/binary" {
declare module.exports: any;
}
declare module "lib0/broadcastchannel" {
declare module.exports: any;
}
declare module "lib0/buffer" {
declare module.exports: any;
}
declare module "lib0/component" {
declare module.exports: any;
}
declare module "lib0/conditions" {
declare module.exports: any;
}
declare module "lib0/decoding" {
declare module.exports: any;
}
declare module "lib0/diff" {
declare module.exports: any;
}
declare module "lib0/dist/test" {
declare module.exports: any;
}
declare module "lib0/dom" {
declare module.exports: any;
}
declare module "lib0/encoding" {
declare module.exports: any;
}
declare module "lib0/environment" {
declare module.exports: any;
}
declare module "lib0/error" {
declare module.exports: any;
}
declare module "lib0/eventloop" {
declare module.exports: any;
}
declare module "lib0/function" {
declare module.exports: any;
}
declare module "lib0/indexeddb" {
declare module.exports: any;
}
declare module "lib0/isomorphic" {
declare module.exports: any;
}
declare module "lib0/iterator" {
declare module.exports: any;
}
declare module "lib0/json" {
declare module.exports: any;
}
declare module "lib0/logging" {
declare module.exports: any;
}
declare module "lib0/map" {
declare module.exports: any;
}
declare module "lib0/math" {
declare module.exports: any;
}
declare module "lib0/metric" {
declare module.exports: any;
}
declare module "lib0/mutex" {
declare module.exports: any;
}
declare module "lib0/number" {
declare module.exports: any;
}
declare module "lib0/object" {
declare module.exports: any;
}
declare module "lib0/observable" {
declare module.exports: any;
}
declare module "lib0/pair" {
declare module.exports: any;
}
declare module "lib0/prng" {
declare module.exports: any;
}
declare module "lib0/prng/Mt19937" {
declare module.exports: any;
}
declare module "lib0/prng/Xoroshiro128plus" {
declare module.exports: any;
}
declare module "lib0/prng/Xorshift32" {
declare module.exports: any;
}
declare module "lib0/promise" {
declare module.exports: any;
}
declare module "lib0/queue" {
declare module.exports: any;
}
declare module "lib0/random" {
declare module.exports: any;
}
declare module "lib0/set" {
declare module.exports: any;
}
declare module "lib0/sort" {
declare module.exports: any;
}
declare module "lib0/statistics" {
declare module.exports: any;
}
declare module "lib0/storage" {
declare module.exports: any;
}
declare module "lib0/string" {
declare module.exports: any;
}
declare module "lib0/symbol" {
declare module.exports: any;
}
declare module "lib0/test" {
declare module.exports: any;
}
declare module "lib0/testing" {
declare module.exports: any;
}
declare module "lib0/time" {
declare module.exports: any;
}
declare module "lib0/tree" {
declare module.exports: any;
}
declare module "lib0/url" {
declare module.exports: any;
}
declare module "lib0/websocket" {
declare module.exports: any;
}
// Filename aliases
declare module "lib0/array.js" {
declare module.exports: $Exports<"lib0/array">;
}
declare module "lib0/bin/gendocs.js" {
declare module.exports: $Exports<"lib0/bin/gendocs">;
}
declare module "lib0/binary.js" {
declare module.exports: $Exports<"lib0/binary">;
}
declare module "lib0/broadcastchannel.js" {
declare module.exports: $Exports<"lib0/broadcastchannel">;
}
declare module "lib0/buffer.js" {
declare module.exports: $Exports<"lib0/buffer">;
}
declare module "lib0/component.js" {
declare module.exports: $Exports<"lib0/component">;
}
declare module "lib0/conditions.js" {
declare module.exports: $Exports<"lib0/conditions">;
}
declare module "lib0/decoding.js" {
declare module.exports: $Exports<"lib0/decoding">;
}
declare module "lib0/dist/decoding.cjs" {
declare module.exports: $Exports<"lib0/decoding">;
}
declare module "lib0/diff.js" {
declare module.exports: $Exports<"lib0/diff">;
}
declare module "lib0/dist/test.js" {
declare module.exports: $Exports<"lib0/dist/test">;
}
declare module "lib0/dom.js" {
declare module.exports: $Exports<"lib0/dom">;
}
declare module "lib0/encoding.js" {
declare module.exports: $Exports<"lib0/encoding">;
}
declare module "lib0/dist/encoding.cjs" {
declare module.exports: $Exports<"lib0/encoding">;
}
declare module "lib0/environment.js" {
declare module.exports: $Exports<"lib0/environment">;
}
declare module "lib0/error.js" {
declare module.exports: $Exports<"lib0/error">;
}
declare module "lib0/eventloop.js" {
declare module.exports: $Exports<"lib0/eventloop">;
}
declare module "lib0/function.js" {
declare module.exports: $Exports<"lib0/function">;
}
declare module "lib0/index" {
declare module.exports: $Exports<"lib0">;
}
declare module "lib0/index.js" {
declare module.exports: $Exports<"lib0">;
}
declare module "lib0/indexeddb.js" {
declare module.exports: $Exports<"lib0/indexeddb">;
}
declare module "lib0/isomorphic.js" {
declare module.exports: $Exports<"lib0/isomorphic">;
}
declare module "lib0/iterator.js" {
declare module.exports: $Exports<"lib0/iterator">;
}
declare module "lib0/json.js" {
declare module.exports: $Exports<"lib0/json">;
}
declare module "lib0/logging.js" {
declare module.exports: $Exports<"lib0/logging">;
}
declare module "lib0/map.js" {
declare module.exports: $Exports<"lib0/map">;
}
declare module "lib0/math.js" {
declare module.exports: $Exports<"lib0/math">;
}
declare module "lib0/metric.js" {
declare module.exports: $Exports<"lib0/metric">;
}
declare module "lib0/mutex.js" {
declare module.exports: $Exports<"lib0/mutex">;
}
declare module "lib0/dist/mutex.cjs" {
declare module.exports: $Exports<"lib0/mutex">;
}
declare module "lib0/number.js" {
declare module.exports: $Exports<"lib0/number">;
}
declare module "lib0/object.js" {
declare module.exports: $Exports<"lib0/object">;
}
declare module "lib0/observable.js" {
declare module.exports: $Exports<"lib0/observable">;
}
declare module "lib0/pair.js" {
declare module.exports: $Exports<"lib0/pair">;
}
declare module "lib0/prng.js" {
declare module.exports: $Exports<"lib0/prng">;
}
declare module "lib0/prng/Mt19937.js" {
declare module.exports: $Exports<"lib0/prng/Mt19937">;
}
declare module "lib0/prng/Xoroshiro128plus.js" {
declare module.exports: $Exports<"lib0/prng/Xoroshiro128plus">;
}
declare module "lib0/prng/Xorshift32.js" {
declare module.exports: $Exports<"lib0/prng/Xorshift32">;
}
declare module "lib0/promise.js" {
declare module.exports: $Exports<"lib0/promise">;
}
declare module "lib0/queue.js" {
declare module.exports: $Exports<"lib0/queue">;
}
declare module "lib0/random.js" {
declare module.exports: $Exports<"lib0/random">;
}
declare module "lib0/set.js" {
declare module.exports: $Exports<"lib0/set">;
}
declare module "lib0/sort.js" {
declare module.exports: $Exports<"lib0/sort">;
}
declare module "lib0/statistics.js" {
declare module.exports: $Exports<"lib0/statistics">;
}
declare module "lib0/storage.js" {
declare module.exports: $Exports<"lib0/storage">;
}
declare module "lib0/string.js" {
declare module.exports: $Exports<"lib0/string">;
}
declare module "lib0/symbol.js" {
declare module.exports: $Exports<"lib0/symbol">;
}
declare module "lib0/test.js" {
declare module.exports: $Exports<"lib0/test">;
}
declare module "lib0/testing.js" {
declare module.exports: $Exports<"lib0/testing">;
}
declare module "lib0/time.js" {
declare module.exports: $Exports<"lib0/time">;
}
declare module "lib0/tree.js" {
declare module.exports: $Exports<"lib0/tree">;
}
declare module "lib0/url.js" {
declare module.exports: $Exports<"lib0/url">;
}
declare module "lib0/websocket.js" {
declare module.exports: $Exports<"lib0/websocket">;
}
+39
View File
@@ -0,0 +1,39 @@
// flow-typed signature: 71e55e30d387153cf804d226f95c0ad8
// flow-typed version: <<STUB>>/y-indexeddb_v^9.0.5/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'y-indexeddb'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
declare module 'y-indexeddb' {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'y-indexeddb/dist/test' {
declare module.exports: any;
}
declare module 'y-indexeddb/src/y-indexeddb' {
declare module.exports: any;
}
// Filename aliases
declare module 'y-indexeddb/dist/test.js' {
declare module.exports: $Exports<'y-indexeddb/dist/test'>;
}
declare module 'y-indexeddb/src/y-indexeddb.js' {
declare module.exports: $Exports<'y-indexeddb/src/y-indexeddb'>;
}
+68
View File
@@ -0,0 +1,68 @@
// flow-typed signature: 2db53ec5dbb577a4e27bc465cd4670f3
// flow-typed version: <<STUB>>/y-prosemirror_v^0.3.7/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'y-prosemirror'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
// @flow
declare module "y-prosemirror" {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module "y-prosemirror/dist/test" {
declare module.exports: any;
}
declare module "y-prosemirror/src/lib" {
declare module.exports: any;
}
declare module "y-prosemirror/src/plugins/cursor-plugin" {
declare module.exports: any;
}
declare module "y-prosemirror/src/plugins/sync-plugin" {
declare module.exports: any;
}
declare module "y-prosemirror/src/plugins/undo-plugin" {
declare module.exports: any;
}
declare module "y-prosemirror/src/y-prosemirror" {
declare module.exports: any;
}
// Filename aliases
declare module "y-prosemirror/dist/test.js" {
declare module.exports: $Exports<"y-prosemirror/dist/test">;
}
declare module "y-prosemirror/src/lib.js" {
declare module.exports: $Exports<"y-prosemirror/src/lib">;
}
declare module "y-prosemirror/src/plugins/cursor-plugin.js" {
declare module.exports: $Exports<"y-prosemirror/src/plugins/cursor-plugin">;
}
declare module "y-prosemirror/src/plugins/sync-plugin.js" {
declare module.exports: $Exports<"y-prosemirror/src/plugins/sync-plugin">;
}
declare module "y-prosemirror/src/plugins/undo-plugin.js" {
declare module.exports: $Exports<"y-prosemirror/src/plugins/undo-plugin">;
}
declare module "y-prosemirror/src/y-prosemirror.js" {
declare module.exports: $Exports<"y-prosemirror/src/y-prosemirror">;
}
+67
View File
@@ -0,0 +1,67 @@
// flow-typed signature: 3ef5e4dd42591ff15af5f507abd6aa97
// flow-typed version: <<STUB>>/y-protocols_v^1.0.1/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'y-protocols'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
// @flow
declare module "y-protocols" {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module "y-protocols/auth" {
declare module.exports: any;
}
declare module "y-protocols/awareness" {
declare module.exports: any;
}
declare module "y-protocols/awareness.test" {
declare module.exports: any;
}
declare module "y-protocols/dist/test" {
declare module.exports: any;
}
declare module "y-protocols/sync" {
declare module.exports: any;
}
// Filename aliases
declare module "y-protocols/auth.js" {
declare module.exports: $Exports<"y-protocols/auth">;
}
declare module "y-protocols/awareness.js" {
declare module.exports: $Exports<"y-protocols/awareness">;
}
declare module "y-protocols/dist/awareness.cjs" {
declare module.exports: $Exports<"y-protocols/awareness">;
}
declare module "y-protocols/awareness.test.js" {
declare module.exports: $Exports<"y-protocols/awareness.test">;
}
declare module "y-protocols/dist/test.js" {
declare module.exports: $Exports<"y-protocols/dist/test">;
}
declare module "y-protocols/sync.js" {
declare module.exports: $Exports<"y-protocols/sync">;
}
declare module "y-protocols/dist/sync.cjs" {
declare module.exports: $Exports<"y-protocols/sync">;
}
+430
View File
@@ -0,0 +1,430 @@
// flow-typed signature: ec89eac307897bef104c76ce1dd14a4d
// flow-typed version: <<STUB>>/yjs_v^13.4.1/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'yjs'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
declare module 'yjs' {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'yjs/dist/tests' {
declare module.exports: any;
}
declare module 'yjs/docs/scripts/jquery.min' {
declare module.exports: any;
}
declare module 'yjs/docs/scripts/linenumber' {
declare module.exports: any;
}
declare module 'yjs/docs/scripts/prettify/lang-css' {
declare module.exports: any;
}
declare module 'yjs/docs/scripts/prettify/prettify' {
declare module.exports: any;
}
declare module 'yjs/docs/scripts/tui-doc' {
declare module.exports: any;
}
declare module 'yjs/src' {
declare module.exports: any;
}
declare module 'yjs/src/internals' {
declare module.exports: any;
}
declare module 'yjs/src/structs/AbstractStruct' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentAny' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentBinary' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentDeleted' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentDoc' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentEmbed' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentFormat' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentJSON' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentString' {
declare module.exports: any;
}
declare module 'yjs/src/structs/ContentType' {
declare module.exports: any;
}
declare module 'yjs/src/structs/GC' {
declare module.exports: any;
}
declare module 'yjs/src/structs/Item' {
declare module.exports: any;
}
declare module 'yjs/src/types/AbstractType' {
declare module.exports: any;
}
declare module 'yjs/src/types/YArray' {
declare module.exports: any;
}
declare module 'yjs/src/types/YMap' {
declare module.exports: any;
}
declare module 'yjs/src/types/YText' {
declare module.exports: any;
}
declare module 'yjs/src/types/YXmlElement' {
declare module.exports: any;
}
declare module 'yjs/src/types/YXmlEvent' {
declare module.exports: any;
}
declare module 'yjs/src/types/YXmlFragment' {
declare module.exports: any;
}
declare module 'yjs/src/types/YXmlHook' {
declare module.exports: any;
}
declare module 'yjs/src/types/YXmlText' {
declare module.exports: any;
}
declare module 'yjs/src/utils/AbstractConnector' {
declare module.exports: any;
}
declare module 'yjs/src/utils/DeleteSet' {
declare module.exports: any;
}
declare module 'yjs/src/utils/Doc' {
declare module.exports: any;
}
declare module 'yjs/src/utils/encoding' {
declare module.exports: any;
}
declare module 'yjs/src/utils/EventHandler' {
declare module.exports: any;
}
declare module 'yjs/src/utils/ID' {
declare module.exports: any;
}
declare module 'yjs/src/utils/isParentOf' {
declare module.exports: any;
}
declare module 'yjs/src/utils/logging' {
declare module.exports: any;
}
declare module 'yjs/src/utils/PermanentUserData' {
declare module.exports: any;
}
declare module 'yjs/src/utils/RelativePosition' {
declare module.exports: any;
}
declare module 'yjs/src/utils/Snapshot' {
declare module.exports: any;
}
declare module 'yjs/src/utils/StructStore' {
declare module.exports: any;
}
declare module 'yjs/src/utils/Transaction' {
declare module.exports: any;
}
declare module 'yjs/src/utils/UndoManager' {
declare module.exports: any;
}
declare module 'yjs/src/utils/UpdateDecoder' {
declare module.exports: any;
}
declare module 'yjs/src/utils/UpdateEncoder' {
declare module.exports: any;
}
declare module 'yjs/src/utils/YEvent' {
declare module.exports: any;
}
declare module 'yjs/tests/compatibility.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/doc.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/encoding.tests' {
declare module.exports: any;
}
declare module 'yjs/tests' {
declare module.exports: any;
}
declare module 'yjs/tests/snapshot.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/testHelper' {
declare module.exports: any;
}
declare module 'yjs/tests/undo-redo.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/y-array.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/y-map.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/y-text.tests' {
declare module.exports: any;
}
declare module 'yjs/tests/y-xml.tests' {
declare module.exports: any;
}
// Filename aliases
declare module 'yjs/dist/tests.js' {
declare module.exports: $Exports<'yjs/dist/tests'>;
}
declare module 'yjs/docs/scripts/jquery.min.js' {
declare module.exports: $Exports<'yjs/docs/scripts/jquery.min'>;
}
declare module 'yjs/docs/scripts/linenumber.js' {
declare module.exports: $Exports<'yjs/docs/scripts/linenumber'>;
}
declare module 'yjs/docs/scripts/prettify/lang-css.js' {
declare module.exports: $Exports<'yjs/docs/scripts/prettify/lang-css'>;
}
declare module 'yjs/docs/scripts/prettify/prettify.js' {
declare module.exports: $Exports<'yjs/docs/scripts/prettify/prettify'>;
}
declare module 'yjs/docs/scripts/tui-doc.js' {
declare module.exports: $Exports<'yjs/docs/scripts/tui-doc'>;
}
declare module 'yjs/src/index' {
declare module.exports: $Exports<'yjs/src'>;
}
declare module 'yjs/src/index.js' {
declare module.exports: $Exports<'yjs/src'>;
}
declare module 'yjs/src/internals.js' {
declare module.exports: $Exports<'yjs/src/internals'>;
}
declare module 'yjs/src/structs/AbstractStruct.js' {
declare module.exports: $Exports<'yjs/src/structs/AbstractStruct'>;
}
declare module 'yjs/src/structs/ContentAny.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentAny'>;
}
declare module 'yjs/src/structs/ContentBinary.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentBinary'>;
}
declare module 'yjs/src/structs/ContentDeleted.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentDeleted'>;
}
declare module 'yjs/src/structs/ContentDoc.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentDoc'>;
}
declare module 'yjs/src/structs/ContentEmbed.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentEmbed'>;
}
declare module 'yjs/src/structs/ContentFormat.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentFormat'>;
}
declare module 'yjs/src/structs/ContentJSON.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentJSON'>;
}
declare module 'yjs/src/structs/ContentString.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentString'>;
}
declare module 'yjs/src/structs/ContentType.js' {
declare module.exports: $Exports<'yjs/src/structs/ContentType'>;
}
declare module 'yjs/src/structs/GC.js' {
declare module.exports: $Exports<'yjs/src/structs/GC'>;
}
declare module 'yjs/src/structs/Item.js' {
declare module.exports: $Exports<'yjs/src/structs/Item'>;
}
declare module 'yjs/src/types/AbstractType.js' {
declare module.exports: $Exports<'yjs/src/types/AbstractType'>;
}
declare module 'yjs/src/types/YArray.js' {
declare module.exports: $Exports<'yjs/src/types/YArray'>;
}
declare module 'yjs/src/types/YMap.js' {
declare module.exports: $Exports<'yjs/src/types/YMap'>;
}
declare module 'yjs/src/types/YText.js' {
declare module.exports: $Exports<'yjs/src/types/YText'>;
}
declare module 'yjs/src/types/YXmlElement.js' {
declare module.exports: $Exports<'yjs/src/types/YXmlElement'>;
}
declare module 'yjs/src/types/YXmlEvent.js' {
declare module.exports: $Exports<'yjs/src/types/YXmlEvent'>;
}
declare module 'yjs/src/types/YXmlFragment.js' {
declare module.exports: $Exports<'yjs/src/types/YXmlFragment'>;
}
declare module 'yjs/src/types/YXmlHook.js' {
declare module.exports: $Exports<'yjs/src/types/YXmlHook'>;
}
declare module 'yjs/src/types/YXmlText.js' {
declare module.exports: $Exports<'yjs/src/types/YXmlText'>;
}
declare module 'yjs/src/utils/AbstractConnector.js' {
declare module.exports: $Exports<'yjs/src/utils/AbstractConnector'>;
}
declare module 'yjs/src/utils/DeleteSet.js' {
declare module.exports: $Exports<'yjs/src/utils/DeleteSet'>;
}
declare module 'yjs/src/utils/Doc.js' {
declare module.exports: $Exports<'yjs/src/utils/Doc'>;
}
declare module 'yjs/src/utils/encoding.js' {
declare module.exports: $Exports<'yjs/src/utils/encoding'>;
}
declare module 'yjs/src/utils/EventHandler.js' {
declare module.exports: $Exports<'yjs/src/utils/EventHandler'>;
}
declare module 'yjs/src/utils/ID.js' {
declare module.exports: $Exports<'yjs/src/utils/ID'>;
}
declare module 'yjs/src/utils/isParentOf.js' {
declare module.exports: $Exports<'yjs/src/utils/isParentOf'>;
}
declare module 'yjs/src/utils/logging.js' {
declare module.exports: $Exports<'yjs/src/utils/logging'>;
}
declare module 'yjs/src/utils/PermanentUserData.js' {
declare module.exports: $Exports<'yjs/src/utils/PermanentUserData'>;
}
declare module 'yjs/src/utils/RelativePosition.js' {
declare module.exports: $Exports<'yjs/src/utils/RelativePosition'>;
}
declare module 'yjs/src/utils/Snapshot.js' {
declare module.exports: $Exports<'yjs/src/utils/Snapshot'>;
}
declare module 'yjs/src/utils/StructStore.js' {
declare module.exports: $Exports<'yjs/src/utils/StructStore'>;
}
declare module 'yjs/src/utils/Transaction.js' {
declare module.exports: $Exports<'yjs/src/utils/Transaction'>;
}
declare module 'yjs/src/utils/UndoManager.js' {
declare module.exports: $Exports<'yjs/src/utils/UndoManager'>;
}
declare module 'yjs/src/utils/UpdateDecoder.js' {
declare module.exports: $Exports<'yjs/src/utils/UpdateDecoder'>;
}
declare module 'yjs/src/utils/UpdateEncoder.js' {
declare module.exports: $Exports<'yjs/src/utils/UpdateEncoder'>;
}
declare module 'yjs/src/utils/YEvent.js' {
declare module.exports: $Exports<'yjs/src/utils/YEvent'>;
}
declare module 'yjs/tests/compatibility.tests.js' {
declare module.exports: $Exports<'yjs/tests/compatibility.tests'>;
}
declare module 'yjs/tests/doc.tests.js' {
declare module.exports: $Exports<'yjs/tests/doc.tests'>;
}
declare module 'yjs/tests/encoding.tests.js' {
declare module.exports: $Exports<'yjs/tests/encoding.tests'>;
}
declare module 'yjs/tests/index' {
declare module.exports: $Exports<'yjs/tests'>;
}
declare module 'yjs/tests/index.js' {
declare module.exports: $Exports<'yjs/tests'>;
}
declare module 'yjs/tests/snapshot.tests.js' {
declare module.exports: $Exports<'yjs/tests/snapshot.tests'>;
}
declare module 'yjs/tests/testHelper.js' {
declare module.exports: $Exports<'yjs/tests/testHelper'>;
}
declare module 'yjs/tests/undo-redo.tests.js' {
declare module.exports: $Exports<'yjs/tests/undo-redo.tests'>;
}
declare module 'yjs/tests/y-array.tests.js' {
declare module.exports: $Exports<'yjs/tests/y-array.tests'>;
}
declare module 'yjs/tests/y-map.tests.js' {
declare module.exports: $Exports<'yjs/tests/y-map.tests'>;
}
declare module 'yjs/tests/y-text.tests.js' {
declare module.exports: $Exports<'yjs/tests/y-text.tests'>;
}
declare module 'yjs/tests/y-xml.tests.js' {
declare module.exports: $Exports<'yjs/tests/y-xml.tests'>;
}
+22 -17
View File
@@ -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",
+8 -4
View File
@@ -3,7 +3,8 @@
exports[`#users.activate should activate a suspended user 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
@@ -55,7 +56,8 @@ Object {
exports[`#users.demote should demote an admin 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
@@ -175,7 +177,8 @@ Object {
exports[`#users.promote should promote a new admin 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
@@ -236,7 +239,8 @@ Object {
exports[`#users.suspend should suspend an user 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
+2 -2
View File
@@ -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}`;
+1 -1
View File
@@ -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({
+2 -2
View File
@@ -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
View File
@@ -999,7 +999,6 @@ router.post("documents.update", auth(), async (ctx) => {
const editorVersion = ctx.headers["x-editor-version"];
ctx.assertPresent(id, "id is required");
ctx.assertPresent(title || text, "title or text is required");
if (append) ctx.assertPresent(text, "Text is required while appending");
const user = ctx.state.user;
@@ -1011,6 +1010,7 @@ router.post("documents.update", auth(), async (ctx) => {
}
const previousTitle = document.title;
const willPublish = publish && !document.published;
// Update document
if (title) document.title = title;
@@ -1025,67 +1025,61 @@ router.post("documents.update", auth(), async (ctx) => {
document.lastModifiedById = user.id;
const { collection } = document;
let transaction;
try {
transaction = await sequelize.transaction();
if (document.changed() || willPublish) {
let transaction;
try {
transaction = await sequelize.transaction();
if (publish) {
await document.publish({ transaction });
} else {
await document.save({ autosave, transaction });
}
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
}
throw err;
}
if (publish) {
await document.publish(user.id, { transaction });
} else {
await document.save({ autosave, transaction });
await Event.create({
name: "documents.update",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: {
autosave,
done,
title: document.title,
},
ip: ctx.request.ip,
});
}
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
if (document.title !== previousTitle) {
Event.add({
name: "documents.title_change",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: {
previousTitle,
title: document.title,
},
ip: ctx.request.ip,
});
}
throw err;
}
if (publish) {
await Event.create({
name: "documents.publish",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
} else {
await Event.create({
name: "documents.update",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: {
autosave,
done,
title: document.title,
},
ip: ctx.request.ip,
});
document.updatedBy = user;
document.collection = collection;
}
if (document.title !== previousTitle) {
Event.add({
name: "documents.title_change",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: {
previousTitle,
title: document.title,
},
ip: ctx.request.ip,
});
}
document.updatedBy = user;
document.collection = collection;
ctx.body = {
data: await presentDocument(document),
policies: presentPolicies(user, [document]),
+1 -1
View File
@@ -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);
});
+5
View File
@@ -17,6 +17,7 @@ router.post("team.update", auth(), async (ctx) => {
sharing,
guestSignin,
documentEmbeds,
multiplayerEditor,
} = ctx.body;
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
@@ -30,6 +31,10 @@ router.post("team.update", auth(), async (ctx) => {
if (sharing !== undefined) team.sharing = sharing;
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
if (guestSignin !== undefined) team.guestSignin = guestSignin;
if (multiplayerEditor !== undefined) {
team.multiplayerEditor = multiplayerEditor;
}
if (avatarUrl !== undefined) team.avatarUrl = avatarUrl;
const changes = team.changed();
+1 -1
View File
@@ -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);
+11
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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";
+52
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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";
+2 -2
View File
@@ -16,10 +16,10 @@ jest.mock("aws-sdk", () => {
});
beforeEach(() => {
flushdb();
// $FlowFixMe
sendEmail.mockReset();
return flushdb();
});
describe("accountProvisioner", () => {
+67
View File
@@ -0,0 +1,67 @@
// @flow
import { uniq } from "lodash";
import { schema, serializer } from "rich-markdown-editor";
import { yDocToProsemirror } from "y-prosemirror";
import * as Y from "yjs";
import { Document, Event } from "../models";
export default async function documentUpdater({
documentId,
ydoc,
userId,
done,
}: {
documentId: string,
ydoc: Y.Doc,
userId: string,
done?: boolean,
}) {
const document = await Document.findByPk(documentId);
const state = Y.encodeStateAsUpdate(ydoc);
const node = yDocToProsemirror(schema, ydoc);
const text = serializer.serialize(node);
// extract collaborators from doc user data
const pud = new Y.PermanentUserData(ydoc);
const pudIds = Array.from(pud.clients.values());
const existingIds = document.collaboratorIds;
const collaboratorIds = uniq([...pudIds, ...existingIds]);
if (document.text === text) {
return;
}
await Document.update(
{
text,
state: Buffer.from(state),
updatedAt: new Date(),
lastModifiedById: userId,
collaboratorIds,
},
{
hooks: false,
where: {
id: document.id,
},
}
);
const event = {
name: "documents.update",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: userId,
data: {
multiplayer: true,
title: document.title,
},
};
if (done) {
await Event.create(event);
} else {
await Event.add(event);
}
}
+4 -1
View File
@@ -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
View File
@@ -1,17 +1,21 @@
// @flow
import http from "http";
import * as Sentry from "@sentry/node";
import debug from "debug";
import IO from "socket.io";
import socketRedisAdapter from "socket.io-redis";
import SocketAuth from "socketio-auth";
import app from "./app";
import { Document, Collection, View } from "./models";
import { Team, Document, Collection, View } from "./models";
import * as multiplayer from "./multiplayer";
import policy from "./policies";
import { client, subscriber } from "./redis";
import { getUserForJWT } from "./utils/jwt";
import { checkMigrations } from "./utils/startup";
const server = http.createServer(app.callback());
const log = debug("server");
let io;
const { can } = policy;
@@ -56,6 +60,7 @@ SocketAuth(io, {
}
},
postAuthenticate: async (socket, data) => {
log(`postAuthenticate ${socket.id}`);
const { user } = socket.client;
// the rooms associated with the current team
@@ -72,32 +77,116 @@ SocketAuth(io, {
// join all of the rooms at once
socket.join(rooms);
},
});
// allow the client to request to join rooms
socket.on("join", async (event) => {
// user is joining a collection channel, because their permissions have
// changed, granting them access.
if (event.collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
// receive multiplayer "sync" messages from other nodes (awareness and doc updates),
// applies data to doc if in memory otherwise the request is ignored
io.of("/").adapter.customHook = (event, callback) => {
io.of("/").clients((err, socketIds) => {
if (!socketIds.includes(event.socketId)) {
multiplayer.handleRemoteSync(
event.socketId,
event.documentId,
event.userId,
event.data
);
}
});
callback(true);
};
if (can(user, "read", collection)) {
socket.join(`collection-${event.collectionId}`);
io.on("connection", (socket) => {
socket.on("sync", (event) => {
if (!socket.auth) {
return;
}
const userId = socket.client.user.id;
// handleJoin must be called before handleSync to ensure authentication
// to communicate changes for the document/socket combo. Messages received
// before handleJoin will be logged and discarded.
multiplayer.handleSync(
socket,
event.documentId,
userId,
new Uint8Array(event.data)
);
// forward "sync" messages to all nodes (awareness and doc updates) so
// that any docs held in memory can be kept up to date.
// TODO: optimize by proactively keeping track of which nodes have doc in
// memory? Perf gains for large horizontal scaling.
io.of("/").adapter.customRequest(
{
socketId: socket.id,
documentId: event.documentId,
userId,
data: event.data,
},
(err) => {
if (err) {
log(err);
}
}
);
});
// user is joining a document channel, because they have navigated to
// view a document.
if (event.documentId) {
const document = await Document.findByPk(event.documentId, {
userId: user.id,
});
// allow the client to request to join rooms
socket.on("join", async (event) => {
if (!socket.auth) {
return;
}
if (can(user, "read", document)) {
const room = `document-${event.documentId}`;
log("join", event.documentId, socket.id);
const { user } = socket.client;
await View.touch(event.documentId, user.id, event.isEditing);
// user is joining a collection channel, because their permissions have
// changed, granting them access.
if (event.collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
if (can(user, "read", collection)) {
socket.join(`collection-${event.collectionId}`);
}
}
// user is joining a document channel, because they have navigated to
// view a document.
if (event.documentId) {
const team = await Team.findByPk(user.teamId);
const document = await Document.findByPk(event.documentId, {
userId: user.id,
});
if (can(user, "read", document)) {
const room = `document-${event.documentId}`;
// new logic for multiplayer editing completely changes "presence"
// detection and propagation, so split at a high-level here.
if (team.multiplayerEditor) {
log("joined multiplayer", socket.id);
socket.join(room, () => {
socket.emit("user.join", {
userId: user.id,
documentId: event.documentId,
});
multiplayer.handleJoin({
io,
document,
socket: socket,
documentId: event.documentId,
});
});
} else {
// old deprecated logic to be removed in the future once multiplayer
// has stabilized
await View.touch(event.documentId, user.id);
const editing = await View.findRecentlyEditingByDocument(
event.documentId
);
@@ -111,11 +200,11 @@ SocketAuth(io, {
});
// let this user know who else is already present in the room
io.in(room).clients(async (err, sockets) => {
io.in(room).clients(async (err, socketIds) => {
if (err) {
if (process.env.SENTRY_DSN) {
Sentry.withScope(function (scope) {
scope.setExtra("clients", sockets);
scope.setExtra("clients", socketIds);
Sentry.captureException(err);
});
} else {
@@ -128,7 +217,7 @@ SocketAuth(io, {
// need to make sure that only unique userIds are returned. A Map
// makes this easy.
let userIds = new Map();
for (const socketId of sockets) {
for (const socketId of socketIds) {
const userId = await client.hget(socketId, "userId");
userIds.set(userId, userId);
}
@@ -141,57 +230,71 @@ SocketAuth(io, {
});
}
}
});
}
});
// allow the client to request to leave rooms
socket.on("leave", (event) => {
if (event.collectionId) {
socket.leave(`collection-${event.collectionId}`);
}
if (event.documentId) {
const room = `document-${event.documentId}`;
socket.leave(room, () => {
io.to(room).emit("user.leave", {
userId: user.id,
documentId: event.documentId,
});
});
}
});
// allow the client to request to leave rooms
socket.on("leave", (event) => {
if (!socket.auth) {
return;
}
socket.on("disconnecting", () => {
const rooms = Object.keys(socket.rooms);
if (event.collectionId) {
socket.leave(`collection-${event.collectionId}`);
}
rooms.forEach((room) => {
if (room.startsWith("document-")) {
const documentId = room.replace("document-", "");
io.to(room).emit("user.leave", {
userId: user.id,
documentId,
});
}
});
});
socket.on("presence", async (event) => {
if (event.documentId) {
const room = `document-${event.documentId}`;
const userId = socket.client.user.id;
if (event.documentId && socket.rooms[room]) {
const view = await View.touch(
event.documentId,
user.id,
event.isEditing
);
view.user = user;
io.to(room).emit("user.presence", {
userId: user.id,
socket.leave(room, () => {
io.to(room).emit("user.leave", {
userId,
documentId: event.documentId,
isEditing: event.isEditing,
});
});
multiplayer.handleLeave(socket.id, userId, event.documentId);
}
});
socket.on("disconnecting", () => {
if (!socket.auth) {
return;
}
const rooms = Object.keys(socket.rooms);
rooms.forEach((room) => {
if (room.startsWith("document-")) {
const documentId = room.replace("document-", "");
const userId = socket.client.user.id;
io.to(room).emit("user.leave", {
userId,
documentId,
});
multiplayer.handleLeave(socket.id, userId, documentId);
}
});
},
});
socket.on("presence", async (event) => {
if (!socket.auth) {
return;
}
const room = `document-${event.documentId}`;
const { user } = socket.client;
if (event.documentId && socket.rooms[room]) {
const view = await View.touch(event.documentId, user.id, event.isEditing);
view.user = user;
io.to(room).emit("user.presence", {
userId: user.id,
documentId: event.documentId,
isEditing: event.isEditing,
});
}
});
});
server.on("error", (err) => {
+7
View File
@@ -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');
}
};
+17 -4
View File
@@ -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(
+53 -1
View File
@@ -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);
});
});
+5 -4
View File
@@ -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) {
+13 -1
View File
@@ -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);
});
});
+5
View File
@@ -69,6 +69,11 @@ const Team = sequelize.define(
allowNull: false,
defaultValue: true,
},
multiplayerEditor: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
},
{
paranoid: true,
+7 -2
View File
@@ -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 -1
View File
@@ -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";
+96
View File
@@ -0,0 +1,96 @@
// @flow
import * as encoding from "lib0/dist/encoding.cjs";
import * as mutex from "lib0/dist/mutex.cjs";
import { parser } from "rich-markdown-editor";
import { prosemirrorToYDoc } from "y-prosemirror";
import * as awarenessProtocol from "y-protocols/dist/awareness.cjs";
import * as syncProtocol from "y-protocols/dist/sync.cjs";
import * as Y from "yjs";
import { MESSAGE_AWARENESS, MESSAGE_SYNC } from "../../shared/constants";
import { Document } from "../models";
export default class WSSharedDoc extends Y.Doc {
constructor(document: Document, io: any) {
super({ gc: true });
this.io = io;
this.documentId = document.id;
this.mux = mutex.createMutex();
this.conns = new Map();
this.awareness = new awarenessProtocol.Awareness(this);
this.awareness.setLocalState(null);
if (document.state) {
Y.applyUpdate(this, document.state);
} else {
const node = parser.parse(document.text);
const ydoc = prosemirrorToYDoc(node);
Y.applyUpdate(this, Y.encodeStateAsUpdate(ydoc));
}
this.awareness.on("update", this.awarenessHandler);
this.on("update", this.updateHandler);
}
destroy() {
this.off("update", this.updateHandler);
this.awareness.off("update", this.awarenessHandler);
this.awareness.destroy();
super.destroy();
}
awarenessHandler = (
{
added,
updated,
removed,
}: { added: Array<number>, updated: Array<number>, removed: Array<number> },
socketId: number
) => {
const changedClients = added.concat(updated, removed);
if (socketId !== null) {
const connControlledIDs = this.conns.get(socketId);
if (connControlledIDs !== undefined) {
added.forEach((clientID) => {
connControlledIDs.add(clientID);
});
removed.forEach((clientID) => {
connControlledIDs.delete(clientID);
});
}
}
// broadcast awareness update
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients)
);
const data = encoding.toUint8Array(encoder);
this.io
.to(`document-${this.documentId}`)
.binary(true)
.emit("document.sync", {
documentId: this.documentId,
data,
});
};
updateHandler = (update: Uint8Array, origin: any) => {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.writeUpdate(encoder, update);
const data = encoding.toUint8Array(encoder);
this.io
.to(`document-${this.documentId}`)
.binary(true)
.emit("document.sync", {
documentId: this.documentId,
data,
});
};
}
+242
View File
@@ -0,0 +1,242 @@
// @flow
import debug from "debug";
import * as decoding from "lib0/dist/decoding.cjs";
import * as encoding from "lib0/dist/encoding.cjs";
import { debounce } from "lodash";
import { Socket } from "socket.io-client";
import * as awarenessProtocol from "y-protocols/dist/awareness.cjs";
import * as syncProtocol from "y-protocols/dist/sync.cjs";
import * as Y from "yjs";
import { MESSAGE_AWARENESS, MESSAGE_SYNC } from "../../shared/constants";
import documentUpdater from "../commands/documentUpdater";
import { Document } from "../models";
import WSSharedDoc from "./WSSharedDoc";
const log = debug("multiplayer");
const docs = new Map<string, WSSharedDoc>();
const PERSIST_WAIT = 3000;
export function handleJoin({
io,
socket,
document,
documentId,
}: {
io: any,
socket: Socket,
document: Document,
documentId: string,
}) {
log(`socket ${socket.id} is joining ${documentId}`);
let doc = docs.get(documentId);
if (!doc) {
doc = new WSSharedDoc(document, io);
doc.get("prosemirror", Y.XmlFragment);
if (document.state) {
log(`no existing session for ${documentId} using database state`);
Y.applyUpdate(doc, document.state);
} else {
log(`no existing session for ${documentId} no database state`);
}
doc.on(
"update",
debounce(
async (update, origin: { userId: string, remote?: boolean }) => {
// If the origin is "remote" this means that the transaction came from
// a remote server process, as we're just accepting transactions to
// keep us in sync with another doc there is no need to persist.
if (origin.remote) {
return;
}
log(`persisting doc (${documentId}) to database`);
await documentUpdater({
documentId,
ydoc: doc,
userId: origin.userId,
});
},
PERSIST_WAIT,
{
maxWait: PERSIST_WAIT * 3,
}
)
);
docs.set(documentId, doc);
}
doc.conns.set(socket.id, new Set());
// send sync step 1
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.writeSyncStep1(encoder, doc);
socket.binary(true).emit("document.sync", {
documentId,
data: encoding.toUint8Array(encoder),
});
const awarenessStates = doc.awareness.getStates();
if (awarenessStates.size > 0) {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(
doc.awareness,
Array.from(awarenessStates.keys())
)
);
socket.binary(true).emit("document.sync", {
documentId,
data: encoding.toUint8Array(encoder),
});
}
}
export async function handleLeave(
socketId: string,
userId: string,
documentId: string
) {
let doc = docs.get(documentId);
// this method is called for all leave events, even old-style, so it needs
// to handle attempting to leave when there is no existing connection
if (!doc || !doc.conns.has(socketId)) {
return;
}
// remove ourselves from the awareness state
const controlledIds = doc.conns.get(socketId);
doc.conns.delete(socketId);
awarenessProtocol.removeAwarenessStates(
doc.awareness,
Array.from(controlledIds),
null
);
// last client has left this document connection, time to cleanup and ensure
// we've written the latest state to the database.
// Important note: In multi-server setups this can mean that everyone has left
// on an individual server process, however their may still be other clients
// connected to other processes
// TODO: store connections in redis?
if (doc.conns.size === 0) {
log(`all clients left doc (${documentId}), persisting…`);
await documentUpdater({ documentId, ydoc: doc, userId, done: true });
doc.destroy();
docs.delete(documentId);
}
}
export function handleSync(
socket: Socket,
documentId: string,
userId: string,
message: Uint8Array
) {
// check auth with existence of socketId in set
let doc = docs.get(documentId);
if (!doc) {
log(`received sync message but doc (${documentId}) not yet loaded`);
return;
}
if (!doc.conns.get(socket.id)) {
log(
`received sync message but socket (${socket.id}) has not joined doc (${documentId})`
);
return;
}
const encoder = encoding.createEncoder();
const decoder = decoding.createDecoder(message);
const messageType = decoding.readVarUint(decoder);
switch (messageType) {
case MESSAGE_SYNC: {
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.readSyncMessage(decoder, encoder, doc, { userId });
if (encoding.length(encoder) > 1) {
socket.binary(true).emit("document.sync", {
documentId,
data: encoding.toUint8Array(encoder),
});
}
break;
}
case MESSAGE_AWARENESS: {
awarenessProtocol.applyAwarenessUpdate(
doc.awareness,
decoding.readVarUint8Array(decoder),
socket.id
);
break;
}
default:
}
}
export function handleRemoteSync(
socketId: string,
documentId: string,
userId: string,
data: {
type: string,
data: ArrayBuffer,
}
) {
let doc = docs.get(documentId);
if (!doc) {
if (process.env.NODE_ENV === "development") {
log(`received remote sync message but doc (${documentId}) not loaded`);
}
return;
}
if (!doc.conns.get(socketId)) {
if (process.env.NODE_ENV === "development") {
log(
`received remote sync message but socket (${socketId}) has not joined doc (${documentId})`
);
}
return;
}
// Note: This is different to handleSync parsing moved to here so that we
// can avoid conversion steps if the doc doesn't already exist in memory.
const message = new Uint8Array(Buffer.from(data.data));
const encoder = encoding.createEncoder();
const decoder = decoding.createDecoder(message);
const messageType = decoding.readVarUint(decoder);
switch (messageType) {
case MESSAGE_SYNC: {
encoding.writeVarUint(encoder, MESSAGE_SYNC);
syncProtocol.readSyncMessage(decoder, encoder, doc, {
userId,
remote: true,
});
break;
}
case MESSAGE_AWARENESS: {
awarenessProtocol.applyAwarenessUpdate(
doc.awareness,
decoding.readVarUint8Array(decoder),
socketId
);
break;
}
default:
}
}
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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);
});
+1
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -9,6 +9,7 @@ export default function present(team: Team) {
sharing: team.sharing,
documentEmbeds: team.documentEmbeds,
guestSignin: team.guestSignin,
multiplayerEditor: team.multiplayerEditor,
subdomain: team.subdomain,
domain: team.domain,
url: team.url,
+2
View File
@@ -10,6 +10,7 @@ type UserPresentation = {
name: string,
avatarUrl: ?string,
email?: string,
color: string,
isAdmin: boolean,
isSuspended: boolean,
isViewer: boolean,
@@ -25,6 +26,7 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
userData.isViewer = user.isViewer;
userData.isSuspended = user.isSuspended;
userData.avatarUrl = user.avatarUrl;
userData.color = user.color;
userData.lastActiveAt = user.lastActiveAt;
if (options.includeDetails) {
+33 -5
View File
@@ -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);
+12 -4
View File
@@ -1,6 +1,6 @@
// @flow
import type { DocumentEvent, RevisionEvent } from "../events";
import { Document, Backlink } from "../models";
import { Document, Team, Backlink } from "../models";
import { Op } from "../sequelize";
import parseDocumentIds from "../utils/parseDocumentIds";
import slugify from "../utils/slugify";
@@ -78,12 +78,20 @@ export default class Backlinks {
break;
}
case "documents.title_change": {
// might as well check
const { title, previousTitle } = event.data;
if (!previousTitle || title === previousTitle) {
break;
}
const document = await Document.findByPk(event.documentId);
if (!document) return;
// might as well check
const { title, previousTitle } = event.data;
if (!previousTitle || title === previousTitle) break;
// TODO: Handle re-writing of titles into CRDT
const team = await Team.findByPk(document.teamId);
if (team.multiplayerEditor) {
break;
}
// update any link titles in documents that lead to this one
const backlinks = await Backlink.findAll({

Some files were not shown because too many files have changed in this diff Show More