Compare commits

..

59 Commits

Author SHA1 Message Date
Tom Moor d4c594423f More email styling 2021-07-08 22:38:42 -04:00
Tom Moor 2bf237d54b Merge branch 'main' into email-diff 2021-07-08 21:20:18 -04:00
Tom Moor 9a1c8f07d1 feat: Add documentId filter to events.list (#2287) 2021-07-08 10:12:06 -07:00
Tom Moor 241cb11493 chore: Automate running yarn-deduplicate, see comment:
https://github.com/outline/outline/pull/2283\#discussion_r665301770
2021-07-07 22:26:56 -04:00
Saumya Pandey 8195791bb2 fix: Make search query string user friendly (#2283)
* Upgrade query-string package and skip empty string

* Run yarn-deduplicate command
2021-07-07 18:45:40 -07:00
Saumya Pandey b037ae5dc1 fix: Improve isChildDocument performance (#2284) 2021-07-07 04:53:40 -07:00
Tom Moor aeba8ce4eb fix: Empty context menu when user does not have permission to update collection 2021-07-06 22:02:31 -04:00
Tom Moor 3565e68725 Merge branch 'main' into email-diff 2021-07-06 20:43:51 -04:00
Tom Moor 429c5fba85 0.57.0 2021-07-06 09:12:54 -04:00
Tom Moor 9495ddba25 fix: Restore previous WSS CORS behavior 2021-07-05 23:01:25 -04:00
dependabot[bot] 486a60e97c chore(deps): bump socket.io from 2.3.0 to 2.4.0 (#1831)
Bumps [socket.io](https://github.com/socketio/socket.io) from 2.3.0 to 2.4.0.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/2.4.0/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/2.3.0...2.4.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-05 17:11:40 -07:00
Tom Moor c687745263 fix: E-mail signin on incorrect subdomain should allow the process to continue instead of error
closes #2276
2021-07-05 19:25:21 -04:00
Translate-O-Tron 1b92993b90 fix: New Portuguese, Brazilian translations from Crowdin (#2271) 2021-07-05 13:37:09 -07:00
Tom Moor 181a20a268 fix: More context menu fixes 2021-07-05 16:35:46 -04:00
Tom Moor f8ffa4e25a tweak sidebar item background 2021-07-04 18:44:09 -04:00
Farzad 7e139ca8f7 fix: nested link positions for RTL titles in sidebar (#2272) 2021-07-04 12:08:05 -07:00
Tom Moor bb58db507d fix: ew-resize -> col-resize cursor 2021-07-04 11:54:29 -04:00
Translate-O-Tron 49bf86d6d9 New Crowdin updates (#2268) 2021-07-04 06:54:52 -07:00
Tom Moor 286a15cf10 fix: Clicking dropdown menu items in FF (#2269)
* fix: Clicking dropdown menu items in FF
closes #2264

* fix: Anchor items, add comment

* fix: CI test memory issues
2021-07-04 06:54:40 -07:00
Tom Moor f65469b777 lockfile 2021-07-03 21:22:52 -04:00
Translate-O-Tron fe65a79d66 New Crowdin updates (#2267) 2021-07-03 07:02:01 -07:00
Tom Moor a1d5ac0907 RTL document support (#2263)
* Basic RTL support in documents

* fix: DocumentListItem and ReferenceListItem for RTL content
2021-07-03 07:00:10 -07:00
Tom Moor 04eabe68a7 feat: Enable traditional Chinese translations (#2266) 2021-07-02 12:08:08 -07:00
Tom Moor 1c0c694c22 fix: Email auth should allow same guest user on multiple subdomains (#2252)
* test: Add email auth tests to establish current state of system

* fix: Update logic to account for dupe emails used between subdomains

* test

* test
2021-07-02 12:07:43 -07:00
Translate-O-Tron 2ae74f2834 New Crowdin updates (#2262) 2021-07-02 11:23:02 -07:00
Tom Moor 0f01fc5faa test: Reduce memory usage by not requiring stores into all (#2265) 2021-07-02 11:16:07 -07:00
Tom Moor 7f1322b7ba fix: Down arrow in search input should move focus to results (#2257)
closes #2253
2021-07-01 15:01:30 -07:00
dependabot[bot] 3c98133e24 chore(deps): bump socket.io-parser from 3.3.1 to 3.3.2 (#2258)
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 3.3.1 to 3.3.2.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/3.3.2/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/3.3.1...3.3.2)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-01 12:08:57 -07:00
Tom Moor 088353d61f fix: Data loading state not reset when props change to PaginatedList (#2254)
* fix: Data loading state not reset when significant props change to PaginatedList

closes #2251

* test: Add enzyme and component test
2021-06-26 21:49:25 -07:00
Translate-O-Tron 31180619e1 New Crowdin updates (#2182) 2021-06-26 13:43:54 -07:00
Saumya Pandey 9fccc280d7 fix: Add ability to permanently delete documents in trash (#2192)
* Align false conditions before true

* Update documents.delete endpoint for permanent delete

* Add permanent delete to events table and integrate with socket.io

* Add permanent delete to document menu

* Update parentDocumentId of direct child to null

* Add translation

* Add test for permanent delete

* Add space

* Update app/scenes/DocumentPermanentDelete.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/stores/DocumentsStore.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update server/commands/documentPermanentDeleter.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/DocumentPermanentDelete.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Change socket room from team to collection

* Add translation

* Create log func for commands

* Move tests from utils to permanentDeleter command

* Add additional tests

* Set redirect to trash

* Return promise from beforeEach

* Add undeleted documents validation

* Include deleteAt attribute in db query

* Update server/commands/documentPermanentDeleter.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* tweak language

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-25 16:14:40 -07:00
Tom Moor c69b4efc34 fix: Aligned images do not load in publicly shared documents (#2248) 2021-06-25 10:09:44 -07:00
Tom Moor 61039e9d0d Allow images in email diff 2021-06-25 09:41:34 -07:00
Tom Moor 6d09122d56 test: Deletion 2021-06-24 20:10:42 -07:00
Tom Moor 5fb6097153 Improved diff 2021-06-23 23:58:32 -07:00
Tom Moor ec17874568 Remove test harness 2021-06-22 07:35:38 -07:00
Tom Moor 40c3e9e85f test 2021-06-22 07:27:55 -07:00
Tom Moor 9f739f3788 Merge main 2021-06-22 07:26:45 -07:00
Tom Moor 3cec6b4903 fix: Allow for offline development 2021-06-21 21:40:28 -07:00
Tom Moor ede7f2e3e6 fix: Bump RME (table and image fixes) 2021-06-21 17:39:14 -07:00
Tom Moor f6837b4742 wip 2021-06-20 23:15:04 -07:00
Tom Moor 1560e3c9f7 refactor 2021-06-20 12:49:15 -07:00
Tom Moor ca74908dc5 test 2021-06-20 00:20:37 -07:00
Tom Moor de7ec1119b Integrate into mailer, basic styling 2021-06-19 23:50:36 -07:00
Tom Moor 2093b4297f Merge main 2021-06-19 17:05:19 -07:00
Tom Moor cf8fa5ffa3 fix: Bump RME (checkbox list fixes) 2021-06-18 16:28:27 -07:00
Tom Moor 1a2a0f4264 fix: Long search term causes server error writing query to db (#2237)
closes #2234
2021-06-17 23:23:35 -07:00
Tom Moor 5f3a38bf87 fix: todo list checkbox consistency issue
closes #2179
2021-06-17 22:57:55 -07:00
Tom Moor afff3a6f25 fix: Server error when user cancels OAuth process with Azure (#2231) 2021-06-16 21:45:20 -07:00
Tom Moor b5824879a3 Merge branch 'fix/concat-tags' 2021-06-16 18:36:28 -07:00
Tom Moor 1c82e292e0 fix: Allow embed of private mindmeister embeds
fix: Missing right and bottom border of some embeds
2021-06-16 18:36:21 -07:00
Tom Moor 317289ac2a fix: Error in Datadog tracking, if only we had TS :( 2021-06-16 08:52:54 -07:00
Tom Moor 8331026cb3 fix: No search results from link editor search due to error parsing date (date-fns upgrade) 2021-06-16 07:54:56 -07:00
Tom Moor de285f2b63 feat: Add TLS ciphers option (#2217)
closes #2175
2021-06-15 21:37:41 -07:00
G. Santos d205c48296 docs: Fix SECRET_KEY variable description (#2229)
Updated the description of the SECRET_KEY variable in the .env.sample
file to clarify that the key needs to be 32 bytes long and hex-encoded.
The previous description of "32 character hexadecimal" was confusing
as it left open the possibility of a hex-encoded 16-byte key.
2021-06-15 21:37:19 -07:00
Tom Moor 277c37dae6 fix: Metrics lib to account for multiple server instances 2021-06-15 20:34:46 -07:00
Tom Moor 2c39cd6496 chore: Normalize "new" actions in settings (#2226)
* fix: Unauthorized request to views.list from shared documents

* Bump dep styled-components

* chore: Normalize 'new' actions in settings area to top right
chore: Add translation hooks to API tokens screen
chore: Move API tokens loading to paginated list
2021-06-15 19:10:50 -07:00
Tom Moor d85592b5f3 feat: DataDog metrics (#2228)
* wip

* chore: Change event names, add additional events

* fix: Not counting connect events
2021-06-15 19:10:38 -07:00
Nan Yu 3df82c500b wip 2021-02-21 11:52:00 -08:00
142 changed files with 4288 additions and 3786 deletions
+3 -2
View File
@@ -8,8 +8,7 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
# Generate a unique 32 character hexadecimal key. The format is important as this
# value is fed directly into encryption libraries. You should use `openssl rand -hex 32`
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
@@ -129,6 +128,8 @@ SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
-4
View File
@@ -11,10 +11,6 @@
.*/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
+9
View File
@@ -135,6 +135,15 @@
"description": "wikireply@example.com (optional)",
"required": false
},
"SMTP_SECURE": {
"value": "true",
"description": "Use a secure SMTP connection (optional)",
"required": false
},
"SMTP_TLS_CIPHERS": {
"description": "Override SMTP cipher configuration (optional)",
"required": false
},
"GOOGLE_ANALYTICS_ID": {
"description": "UA-xxxx (optional)",
"required": false
+30
View File
@@ -0,0 +1,30 @@
{
"testURL": "http://localhost",
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/app",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleFileExtensions": [
"js",
"jsx",
"json"
],
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.js"
]
}
+13 -1
View File
@@ -29,15 +29,26 @@ const MenuItem = ({
const handleClick = React.useCallback(
(ev) => {
if (onClick) {
ev.preventDefault();
ev.stopPropagation();
onClick(ev);
}
if (hide) {
hide();
}
},
[hide, onClick]
[onClick, hide]
);
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const handleMouseDown = React.useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
}, []);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
@@ -51,6 +62,7 @@ const MenuItem = ({
$toggleable={selected !== undefined}
as={onClick ? "button" : as}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{selected !== undefined && (
<>
+6 -2
View File
@@ -83,7 +83,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
);
});
function Template({ items, ...menu }: Props): React.Node {
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
@@ -101,7 +101,11 @@ function Template({ items, ...menu }: Props): React.Node {
return [...acc, item];
}, []);
return filtered.map((item, index) => {
return filtered;
}
function Template({ items, ...menu }: Props): React.Node {
return filterTemplateItems(items).map((item, index) => {
if (item.to) {
return (
<MenuItem
+1 -1
View File
@@ -46,7 +46,7 @@ export default function ContextMenu({
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => (
<Position {...props}>
<Background>
<Background dir="auto">
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
+11 -4
View File
@@ -41,7 +41,7 @@ function replaceResultMarks(tag: string) {
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(props: Props) {
function DocumentListItem(props: Props, ref) {
const { t } = useTranslation();
const { policies } = useStores();
const currentUser = useCurrentUser();
@@ -68,6 +68,8 @@ function DocumentListItem(props: Props) {
return (
<DocumentLink
ref={ref}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
@@ -76,8 +78,12 @@ function DocumentListItem(props: Props) {
}}
>
<Content>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
@@ -221,6 +227,7 @@ const DocumentLink = styled(Link)`
const Heading = styled.h3`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 24px;
margin-top: 0;
@@ -251,4 +258,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em;
`;
export default observer(DocumentListItem);
export default observer(React.forwardRef(DocumentListItem));
+2 -1
View File
@@ -11,6 +11,7 @@ import Time from "components/Time";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
@@ -135,7 +136,7 @@ function DocumentMeta({
: 0;
return (
<Container align="center" {...rest}>
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
+2
View File
@@ -14,6 +14,7 @@ type Props = {|
document: Document,
isDraft: boolean,
to?: string,
rtl?: boolean,
|};
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
@@ -62,6 +63,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
}
const Meta = styled(DocumentMeta)`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
margin: -12px 0 2em 0;
font-size: 14px;
position: relative;
+1 -1
View File
@@ -56,7 +56,7 @@ function DocumentViews({ document, isOpen }: Props) {
: t("Currently viewing")
: t("Viewed {{ timeAgo }} ago", {
timeAgo: formatDistanceToNow(
view ? new Date(view.lastViewedAt) : new Date()
view ? Date.parse(view.lastViewedAt) : new Date()
),
});
-61
View File
@@ -245,53 +245,6 @@ 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 }) => (
@@ -309,17 +262,3 @@ const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
));
// > .ProseMirror-yjs-cursor:first-child {
// margin-top: 16px;
// }
// p:first-child,
// h1:first-child,
// h2:first-child,
// h3:first-child,
// h4:first-child,
// h5:first-child,
// h6:first-child {
// margin-top: 16px;
// }
-20
View File
@@ -1,20 +0,0 @@
// @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 -1
View File
@@ -1,6 +1,18 @@
// @flow
import { format, formatDistanceToNow } from "date-fns";
import { enUS, de, fr, es, it, ko, ptBR, pt, zhCN, ru } from "date-fns/locale";
import {
enUS,
de,
fr,
es,
it,
ko,
ptBR,
pt,
zhCN,
zhTW,
ru,
} from "date-fns/locale";
import * as React from "react";
import Tooltip from "components/Tooltip";
import useUserLocale from "hooks/useUserLocale";
@@ -15,6 +27,7 @@ const locales = {
pt_BR: ptBR,
pt_PT: pt,
zh_CN: zhCN,
zh_TW: zhTW,
ru_RU: ru,
};
+14 -4
View File
@@ -38,14 +38,24 @@ class PaginatedList extends React.Component<Props> {
}
componentDidUpdate(prevProps: Props) {
if (prevProps.fetch !== this.props.fetch) {
this.fetchResults();
}
if (!isEqual(prevProps.options, this.props.options)) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
) {
this.reset();
this.fetchResults();
}
}
reset = () => {
this.offset = 0;
this.allowLoadMore = true;
this.renderCount = DEFAULT_PAGINATION_LIMIT;
this.isFetching = false;
this.isFetchingMore = false;
this.isLoaded = false;
};
fetchResults = async () => {
if (!this.props.fetch) return;
+84
View File
@@ -0,0 +1,84 @@
// @flow
import "../stores";
import { shallow } from "enzyme";
import * as React from "react";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import { runAllPromises } from "../test/support";
import PaginatedList from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;
it("with no items renders nothing", () => {
const list = shallow(<PaginatedList items={[]} renderItem={render} />);
expect(list).toEqual({});
});
it("with no items renders empty prop", () => {
const list = shallow(
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
/>
);
expect(list.text()).toEqual("Sorry, no results");
});
it("calls fetch with options + pagination on mount", () => {
const fetch = jest.fn();
const options = { id: "one" };
shallow(
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
/>
);
expect(fetch).toHaveBeenCalledWith({
...options,
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
});
it("calls fetch when options prop changes", async () => {
const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill();
const fetch = jest.fn().mockReturnValue(fetchedItems);
const list = shallow(
<PaginatedList
items={[]}
fetch={fetch}
options={{ id: "one" }}
renderItem={render}
/>
);
await runAllPromises();
expect(fetch).toHaveBeenCalledWith({
id: "one",
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
fetch.mockReset();
list.setProps({
fetch,
items: [],
options: { id: "two" },
});
await runAllPromises();
expect(fetch).toHaveBeenCalledWith({
id: "two",
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
});
});
@@ -27,16 +27,19 @@ type Props = {|
parentId?: string,
|};
function DocumentLink({
node,
canUpdate,
collection,
activeDocument,
prefetchDocument,
depth,
index,
parentId,
}: Props) {
function DocumentLink(
{
node,
canUpdate,
collection,
activeDocument,
prefetchDocument,
depth,
index,
parentId,
}: Props,
ref
) {
const { documents, policies } = useStores();
const { t } = useTranslation();
@@ -236,6 +239,7 @@ function DocumentLink({
depth={depth}
exact={false}
showActions={menuOpen}
ref={ref}
menu={
document && !isMoving ? (
<Fade>
@@ -289,5 +293,6 @@ const Disclosure = styled(CollapsedIcon)`
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
const ObservedDocumentLink = observer(DocumentLink);
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
export default ObservedDocumentLink;
@@ -65,6 +65,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
{isEditing ? (
<form onSubmit={handleSave}>
<Input
dir="auto"
type="text"
value={value}
onKeyDown={handleKeyDown}
@@ -7,7 +7,7 @@ const ResizeBorder = styled.div`
bottom: 0;
right: -6px;
width: 12px;
cursor: ew-resize;
cursor: col-resize;
`;
export default ResizeBorder;
@@ -1,4 +1,5 @@
// @flow
import { transparentize } from "polished";
import * as React from "react";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@@ -29,25 +30,28 @@ type Props = {
depth?: number,
};
function SidebarLink({
icon,
children,
onClick,
onMouseEnter,
to,
label,
active,
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
}: Props) {
function SidebarLink(
{
icon,
children,
onClick,
onMouseEnter,
to,
label,
active,
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
}: Props,
ref
) {
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
@@ -78,6 +82,7 @@ function SidebarLink({
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
@@ -141,7 +146,8 @@ const Link = styled(NavLink)`
&:focus {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.black05};
background: ${(props) =>
transparentize("0.25", props.theme.sidebarItemBackground)};
}
${breakpoint("tablet")`
@@ -172,6 +178,9 @@ const Label = styled.div`
width: 100%;
max-height: 4.8em;
line-height: 1.6;
* {
unicode-bidi: plaintext;
}
`;
export default withRouter(withTheme(SidebarLink));
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
+4
View File
@@ -250,6 +250,10 @@ class SocketProvider extends React.Component<Props> {
documents.starredIds.set(event.documentId, false);
});
this.socket.on("documents.permanent_delete", (event) => {
documents.remove(event.documentId);
});
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on("collections.add_user", (event) => {
+4 -1
View File
@@ -17,7 +17,10 @@ export default class Mindmeister extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const chartId = this.props.attrs.matches[4] + this.props.attrs.matches[6];
const chartId =
this.props.attrs.matches[4] +
(this.props.attrs.matches[5] || "") +
(this.props.attrs.matches[6] || "");
return (
<Frame
+9 -9
View File
@@ -11,9 +11,7 @@ import Flex from "components/Flex";
const Iframe = (props) => <iframe title="Embed" {...props} />;
const StyledIframe = styled(Iframe)`
border: 1px solid;
border-color: ${(props) => props.theme.embedBorder};
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
border-radius: ${(props) => (props.$withBar ? "3px 3px 0 0" : "3px")};
display: block;
`;
@@ -70,13 +68,13 @@ class Frame extends React.Component<PropsWithRef> {
<Rounded
width={width}
height={height}
withBar={withBar}
$withBar={withBar}
className={isSelected ? "ProseMirror-selectednode" : ""}
>
{this.isLoaded && (
<Component
ref={forwardedRef}
withBar={withBar}
$withBar={withBar}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
width={width}
height={height}
@@ -108,10 +106,11 @@ class Frame extends React.Component<PropsWithRef> {
}
const Rounded = styled.div`
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
border: 1px solid ${(props) => props.theme.embedBorder};
border-radius: 6px;
overflow: hidden;
width: ${(props) => props.width};
height: ${(props) => (props.withBar ? props.height + 28 : props.height)};
height: ${(props) => (props.$withBar ? props.height + 28 : props.height)};
`;
const Open = styled.a`
@@ -132,11 +131,12 @@ const Title = styled.span`
`;
const Bar = styled(Flex)`
border-top: 1px solid ${(props) => props.theme.embedBorder};
background: ${(props) => props.theme.secondaryBackground};
color: ${(props) => props.theme.textSecondary};
padding: 0 8px;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
user-select: none;
`;
+1 -1
View File
@@ -4,6 +4,6 @@ import useStores from "./useStores";
export default function useCurrentTeam() {
const { auth } = useStores();
invariant(auth.team, "Expected to be authenticated");
invariant(auth.team, "team required");
return auth.team;
}
+48 -42
View File
@@ -12,7 +12,7 @@ import CollectionExport from "scenes/CollectionExport";
import CollectionPermissions from "scenes/CollectionPermissions";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import getDataTransferFiles from "utils/getDataTransferFiles";
@@ -110,6 +110,52 @@ function CollectionMenu({
);
const can = policies.abilities(collection.id);
const items = React.useMemo(
() =>
filterTemplateItems([
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]),
[can, collection, handleNewDocument, handleImportDocument, t]
);
if (!items.length) {
return null;
}
return (
<>
@@ -134,47 +180,7 @@ function CollectionMenu({
onClose={onClose}
aria-label={t("Collection")}
>
<Template
{...menu}
items={[
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]}
/>
<Template {...menu} items={items} />
</ContextMenu>
{renderModals && (
<>
+63 -33
View File
@@ -9,6 +9,7 @@ import styled from "styled-components";
import Document from "models/Document";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentMove from "scenes/DocumentMove";
import DocumentPermanentDelete from "scenes/DocumentPermanentDelete";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import CollectionIcon from "components/CollectionIcon";
import ContextMenu from "components/ContextMenu";
@@ -61,6 +62,10 @@ function DocumentMenu({
const { t } = useTranslation();
const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [
showPermanentDeleteModal,
setShowPermanentDeleteModal,
] = React.useState(false);
const [showMoveModal, setShowMoveModal] = React.useState(false);
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
const file = React.useRef<?HTMLInputElement>();
@@ -327,6 +332,11 @@ function DocumentMenu({
onClick: () => setShowDeleteModal(true),
visible: !!can.delete,
},
{
title: `${t("Permanently delete")}`,
onClick: () => setShowPermanentDeleteModal(true),
visible: can.permanentDelete,
},
{
title: `${t("Move")}`,
onClick: () => setShowMoveModal(true),
@@ -357,40 +367,60 @@ function DocumentMenu({
</ContextMenu>
{renderModals && (
<>
<Modal
title={t("Move {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowMoveModal(false)}
isOpen={showMoveModal}
>
<DocumentMove
document={document}
{can.move && (
<Modal
title={t("Move {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowMoveModal(false)}
/>
</Modal>
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowDeleteModal(false)}
isOpen={showDeleteModal}
>
<DocumentDelete
document={document}
onSubmit={() => setShowDeleteModal(false)}
/>
</Modal>
<Modal
title={t("Create template")}
onRequestClose={() => setShowTemplateModal(false)}
isOpen={showTemplateModal}
>
<DocumentTemplatize
document={document}
onSubmit={() => setShowTemplateModal(false)}
/>
</Modal>
isOpen={showMoveModal}
>
<DocumentMove
document={document}
onRequestClose={() => setShowMoveModal(false)}
/>
</Modal>
)}
{can.delete && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowDeleteModal(false)}
isOpen={showDeleteModal}
>
<DocumentDelete
document={document}
onSubmit={() => setShowDeleteModal(false)}
/>
</Modal>
)}
{can.permanentDelete && (
<Modal
title={t("Permanently delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowPermanentDeleteModal(false)}
isOpen={showPermanentDeleteModal}
>
<DocumentPermanentDelete
document={document}
onSubmit={() => setShowPermanentDeleteModal(false)}
/>
</Modal>
)}
{can.update && (
<Modal
title={t("Create template")}
onRequestClose={() => setShowTemplateModal(false)}
isOpen={showTemplateModal}
>
<DocumentTemplatize
document={document}
onSubmit={() => setShowTemplateModal(false)}
/>
</Modal>
)}
</>
)}
</>
+21 -23
View File
@@ -58,6 +58,26 @@ export default class Document extends BaseModel {
return emoji;
}
/**
* Best-guess the text direction of the document based on the language the
* title is written in. Note: wrapping as a computed getter means that it will
* only be called directly when the title changes.
*/
@computed
get dir(): "rtl" | "ltr" {
const element = document.createElement("p");
element.innerHTML = this.title;
element.style.visibility = "hidden";
element.dir = "auto";
// element must appear in body for direction to be computed
document.body?.appendChild(element);
const direction = window.getComputedStyle(element).direction;
document.body?.removeChild(element);
return direction;
}
@computed
get noun(): string {
return this.template ? "template" : "document";
@@ -76,7 +96,6 @@ export default class Document extends BaseModel {
@computed
get isNew(): boolean {
return (
!!this.publishedAt &&
!this.lastViewedAt &&
differenceInDays(new Date(), new Date(this.createdAt)) < 14
);
@@ -232,27 +251,6 @@ 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;
@@ -286,7 +284,7 @@ export default class Document extends BaseModel {
});
}
throw new Error("Attempting to save without a lastRevision");
throw new Error("Attempting to update without a lastRevision");
} finally {
this.isSaving = false;
}
-1
View File
@@ -8,7 +8,6 @@ class Team extends BaseModel {
avatarUrl: string;
sharing: boolean;
documentEmbeds: boolean;
multiplayerEditor: boolean;
guestSignin: boolean;
subdomain: ?string;
domain: ?string;
-1
View File
@@ -8,7 +8,6 @@ class User extends BaseModel {
id: string;
name: string;
email: string;
color: string;
isAdmin: boolean;
isViewer: boolean;
lastActiveAt: string;
-55
View File
@@ -1,55 +0,0 @@
// @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
@@ -1,356 +0,0 @@
// 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();
}
}
}
+7 -5
View File
@@ -22,8 +22,10 @@ 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 KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
);
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
@@ -64,10 +66,10 @@ export default function AuthenticatedRoutes() {
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={Document}
component={KeyedDocument}
/>
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<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} />
+6 -4
View File
@@ -12,8 +12,10 @@ const Authenticated = React.lazy(() =>
const AuthenticatedRoutes = React.lazy(() =>
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
);
const SharedDocument = React.lazy(() =>
import(/* webpackChunkName: "shared-document" */ "scenes/Document/Shared")
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
);
const Login = React.lazy(() =>
import(/* webpackChunkName: "login" */ "scenes/Login")
@@ -35,11 +37,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={SharedDocument} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={SharedDocument}
component={KeyedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
-2
View File
@@ -2,7 +2,6 @@
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";
@@ -20,7 +19,6 @@ 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} />
+65
View File
@@ -0,0 +1,65 @@
// @flow
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import useStores from "hooks/useStores";
type Props = {|
onSubmit: () => void,
|};
function APITokenNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [isSaving, setIsSaving] = React.useState(false);
const { apiKeys, ui } = useStores();
const { t } = useTranslation();
const handleSubmit = React.useCallback(async () => {
setIsSaving(true);
try {
await apiKeys.create({ name });
ui.showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
ui.showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
}, [t, ui, name, onSubmit, apiKeys]);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
}, []);
return (
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
Name your token something that will help you to remember it's use in
the future, for example "local development", "production", or
"continuous integration".
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? "Creating…" : "Create"}
</Button>
</form>
);
}
export default APITokenNew;
+19 -17
View File
@@ -149,25 +149,27 @@ function CollectionScene() {
/>
</Action>
{can.update && (
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
<Separator />
<Action>
<CollectionMenu
collection={collection}
+25
View File
@@ -0,0 +1,25 @@
// @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
@@ -1,61 +0,0 @@
// @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
/>
);
}
+17 -22
View File
@@ -18,8 +18,10 @@ 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";
@@ -27,7 +29,6 @@ import { isInternalUrl } from "utils/urls";
type Props = {|
match: Match,
location: LocationWithState,
auth: AuthStore,
shares: SharesStore,
documents: DocumentsStore,
policies: PoliciesStore,
@@ -35,7 +36,6 @@ type Props = {|
auth: AuthStore,
ui: UiStore,
history: RouterHistory,
children: (any) => React.Node,
|};
const sharedTreeCache = {};
@@ -121,7 +121,7 @@ class DataLoader extends React.Component<Props> {
return sortBy(
results.map((document) => {
const time = formatDistanceToNow(document.updatedAt, {
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
addSuffix: true,
});
return {
@@ -223,7 +223,7 @@ class DataLoader extends React.Component<Props> {
};
render() {
const { location, policies, auth, ui } = this.props;
const { location, policies, ui } = this.props;
if (this.error) {
return this.error instanceof OfflineError ? (
@@ -233,11 +233,10 @@ class DataLoader extends React.Component<Props> {
);
}
const team = auth.team;
const document = this.document;
const revision = this.revision;
if (!document || !team) {
if (!document) {
return (
<>
<Loading location={location} />
@@ -247,25 +246,21 @@ class DataLoader extends React.Component<Props> {
}
const abilities = policies.abilities(document.id);
const key = team.multiplayerEditor
? ""
: this.isEditing
? "editing"
: "read-only";
return (
<React.Fragment key={key}>
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
{this.isEditing && <HideSidebar ui={ui} />}
{this.props.children({
document,
revision,
abilities,
readOnly: !this.isEditing || !abilities.update || document.isArchived,
onSearchLink: this.onSearchLink,
onCreateLink: this.onCreateLink,
sharedTree: this.sharedTree,
})}
</React.Fragment>
<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>
);
}
}
+10 -60
View File
@@ -6,10 +6,9 @@ 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 } from "react-router-dom";
import type { RouterHistory, Match } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Y from "yjs";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
@@ -18,7 +17,6 @@ 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";
@@ -33,7 +31,6 @@ 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";
@@ -57,6 +54,7 @@ Are you sure you want to discard them?
`;
type Props = {
match: Match,
history: RouterHistory,
location: LocationWithState,
sharedTree: ?NavigationNode,
@@ -64,14 +62,6 @@ 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,
@@ -204,7 +194,7 @@ class DocumentScene extends React.Component<Props> {
autosave?: boolean,
} = {}
) => {
const { document, auth } = this.props;
const { document } = this.props;
// prevent saves when we are already saving
if (document.isSaving) return;
@@ -232,22 +222,10 @@ class DocumentScene extends React.Component<Props> {
this.isPublishing = !!options.publish;
try {
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,
});
}
const savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
this.isDirty = false;
this.lastRevision = savedDocument.revision;
@@ -292,11 +270,6 @@ 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,
@@ -325,10 +298,9 @@ class DocumentScene extends React.Component<Props> {
document,
revision,
readOnly,
abilities = {},
abilities,
auth,
ui,
multiplayer,
match,
} = this.props;
const team = auth.team;
@@ -354,7 +326,7 @@ class DocumentScene extends React.Component<Props> {
auto
>
<Route
path={`${document.url}/move`}
path={`${match.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
@@ -378,12 +350,7 @@ class DocumentScene extends React.Component<Props> {
{!readOnly && (
<>
<Prompt
when={
this.isDirty &&
!this.isUploading &&
!!team &&
!team.multiplayerEditor
}
when={this.isDirty && !this.isUploading}
message={DISCARD_CHANGES}
/>
<Prompt
@@ -442,28 +409,12 @@ 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}
@@ -485,7 +436,6 @@ 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}>
+15 -21
View File
@@ -5,7 +5,6 @@ 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";
@@ -16,8 +15,6 @@ 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";
@@ -27,24 +24,16 @@ type Props = {|
title: string,
document: Document,
isDraft: boolean,
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,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
|};
@observer
class DocumentEditor extends React.Component<Props> {
@observable activeLinkEvent: ?MouseEvent;
ref = React.createRef<HTMLDivElement | HTMLInputElement>();
focusAtStart = () => {
if (this.props.innerRef.current) {
@@ -109,18 +98,15 @@ class DocumentEditor extends React.Component<Props> {
title,
onChangeTitle,
isDraft,
canShowHoverPreviews,
shareId,
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;
@@ -129,8 +115,10 @@ class DocumentEditor extends React.Component<Props> {
{readOnly ? (
<Title
as="div"
ref={this.ref}
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
$isStarred={document.isStarred}
dir="auto"
>
<span>{normalizedTitle}</span>{" "}
{!shareId && <StarButton document={document} size={32} />}
@@ -138,6 +126,7 @@ class DocumentEditor extends React.Component<Props> {
) : (
<Title
type="text"
ref={this.ref}
onChange={onChangeTitle}
onKeyDown={this.handleTitleKeyDown}
placeholder={document.placeholder}
@@ -145,6 +134,7 @@ class DocumentEditor extends React.Component<Props> {
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
autoFocus={!title}
maxLength={MAX_TITLE_LENGTH}
dir="auto"
/>
)}
{!shareId && (
@@ -152,22 +142,26 @@ class DocumentEditor extends React.Component<Props> {
isDraft={isDraft}
document={document}
to={documentHistoryUrl(document)}
rtl={
this.ref.current
? window.getComputedStyle(this.ref.current).direction === "rtl"
: false
}
/>
)}
<EditorComponent
<Editor
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 && canShowHoverPreviews && readOnly && (
{this.activeLinkEvent && !shareId && readOnly && (
<HoverPreview
node={this.activeLinkEvent.target}
event={this.activeLinkEvent}
@@ -1,63 +0,0 @@
// @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>
);
}
@@ -35,7 +35,6 @@ const DocumentLink = styled(Link)`
const Title = styled.h3`
display: flex;
align-items: center;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
@@ -78,7 +77,7 @@ function ReferenceListItem({
}}
{...rest}
>
<Title>
<Title dir="auto">
{document.emoji ? (
<Emoji>{document.emoji}</Emoji>
) : (
@@ -1,113 +1,77 @@
// @flow
import * as React from "react";
import * as Y from "yjs";
import { USER_PRESENCE_INTERVAL } from "shared/constants";
import { SocketContext } from "components/SocketProvider";
import useStores from "hooks/useStores";
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
type Props = {
children: ({
provider: ?WebsocketProvider,
isReconnecting: boolean,
isConnected: boolean,
doc: Y.Doc,
}) => React.Node,
isMultiplayer: boolean,
children?: React.Node,
documentId: string,
userId?: string,
isEditing: boolean,
};
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
);
export default class SocketPresence extends React.Component<Props> {
static contextType = SocketContext;
previousContext: any;
editingInterval: IntervalID;
if (provider) {
provider.once("sync", () => setRemoteSynced(true));
componentDidMount() {
this.editingInterval = setInterval(() => {
if (this.props.isEditing) {
this.emitPresence();
}
}, USER_PRESENCE_INTERVAL);
this.setupOnce();
}
React.useEffect(() => {
return () => {
if (provider) {
provider.destroy();
}
};
}, []);
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();
}
}
if (awareness) {
awareness.on("update", onUpdate);
componentWillUnmount() {
if (this.context) {
this.context.emit("leave", { documentId: this.props.documentId });
this.context.off("authenticated", this.emitJoin);
}
return () => {
if (awareness) {
awareness.off("update", onUpdate);
clearInterval(this.editingInterval);
}
setupOnce = () => {
if (this.context && this.context !== this.previousContext) {
this.previousContext = this.context;
if (this.context.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();
this.context.on("authenticated", () => {
this.emitJoin();
});
}
};
return () => {
if (!context) return;
emitJoin = () => {
if (!this.context) return;
context.emit("leave", { documentId: props.documentId });
context.off("authenticated", emitJoin);
context.off("connect", updateStatus);
context.off("disconnect", updateStatus);
context.off("reconnect", reconnectingStopped);
context.off("reconnect_attempt", setReconnecting);
context.off("reconnect_failed", reconnectingStopped);
};
}, [context, props.documentId, props.userId]);
this.context.emit("join", {
documentId: this.props.documentId,
isEditing: this.props.isEditing,
});
};
return props.children({
isConnected,
isRemoteSynced,
isReconnecting,
provider,
doc,
});
emitPresence = () => {
if (!this.context) return;
this.context.emit("presence", {
documentId: this.props.documentId,
isEditing: this.props.isEditing,
});
};
render() {
return this.props.children || null;
}
}
+1 -64
View File
@@ -1,66 +1,3 @@
// @flow
import * as React from "react";
import { type Match } from "react-router-dom";
import DataLoader from "./components/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>
);
}
export default DataLoader;
+60
View File
@@ -0,0 +1,60 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import Document from "models/Document.js";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
onSubmit: () => void,
|};
function DocumentPermanentDelete({ document, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState(false);
const { t } = useTranslation();
const { ui, documents } = useStores();
const { showToast } = ui;
const history = useHistory();
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
try {
setIsDeleting(true);
await documents.delete(document, { permanent: true });
showToast(t("Document permanently deleted"), { type: "success" });
onSubmit();
history.push("/trash");
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsDeleting(false);
}
},
[document, onSubmit, showToast, t, history, documents]
);
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone."
values={{ documentTitle: document.titleWithDefault }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
</form>
</Flex>
);
}
export default observer(DocumentPermanentDelete);
+7 -4
View File
@@ -50,10 +50,13 @@ class Drafts extends React.Component<Props> {
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
search: queryString.stringify(
{
...queryString.parse(this.props.location.search),
...search,
},
{ skipEmptyString: true }
),
});
};
+10 -7
View File
@@ -6,7 +6,6 @@ import { observer, inject } from "mobx-react";
import { PlusIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import ReactDOM from "react-dom";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, Link } from "react-router-dom";
@@ -103,8 +102,9 @@ class Search extends React.Component<Props> {
if (ev.key === "ArrowDown") {
ev.preventDefault();
if (this.firstDocument) {
const element = ReactDOM.findDOMNode(this.firstDocument);
if (element instanceof HTMLElement) element.focus();
if (this.firstDocument instanceof HTMLElement) {
this.firstDocument.focus();
}
}
}
};
@@ -140,10 +140,13 @@ class Search extends React.Component<Props> {
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
search: queryString.stringify(
{
...queryString.parse(this.props.location.search),
...search,
},
{ skipEmptyString: true }
),
});
};
-70
View File
@@ -1,70 +0,0 @@
// @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);
+20 -13
View File
@@ -4,6 +4,7 @@ import { PlusIcon, GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import GroupNew from "scenes/GroupNew";
import { Action } from "components/Actions";
import Button from "components/Button";
import Empty from "components/Empty";
import GroupListItem from "components/GroupListItem";
@@ -33,25 +34,31 @@ function Groups() {
}, []);
return (
<Scene title={t("Groups")} icon={<GroupIcon color="currentColor" />}>
<Scene
title={t("Groups")}
icon={<GroupIcon color="currentColor" />}
actions={
<>
{can.createGroup && (
<Action>
<Button
type="button"
onClick={handleNewGroupModalOpen}
icon={<PlusIcon />}
>
{`${t("New group")}`}
</Button>
</Action>
)}
</>
}
>
<Heading>{t("Groups")}</Heading>
<HelpText>
<Trans>
Groups can be used to organize and manage the people on your team.
</Trans>
</HelpText>
{can.createGroup && (
<Button
type="button"
onClick={handleNewGroupModalOpen}
icon={<PlusIcon />}
neutral
>
{`${t("New group")}`}
</Button>
)}
<Subheading>{t("All groups")}</Subheading>
<PaginatedList
items={groups.orderedData}
+70 -73
View File
@@ -1,89 +1,86 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import * as React from "react";
import ApiKeysStore from "stores/ApiKeysStore";
import UiStore from "stores/UiStore";
import { useTranslation, Trans } from "react-i18next";
import APITokenNew from "scenes/APITokenNew";
import { Action } from "components/Actions";
import Button from "components/Button";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import Input from "components/Input";
import List from "components/List";
import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import TokenListItem from "./components/TokenListItem";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
type Props = {
apiKeys: ApiKeysStore,
ui: UiStore,
};
function Tokens() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { apiKeys, policies } = useStores();
const [newModalOpen, setNewModalOpen] = React.useState(false);
const can = policies.abilities(team.id);
@observer
class Tokens extends React.Component<Props> {
@observable name: string = "";
const handleNewModalOpen = React.useCallback(() => {
setNewModalOpen(true);
}, []);
componentDidMount() {
this.props.apiKeys.fetchPage({ limit: 100 });
}
const handleNewModalClose = React.useCallback(() => {
setNewModalOpen(false);
}, []);
handleUpdate = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
handleSubmit = async (ev: SyntheticEvent<>) => {
try {
ev.preventDefault();
await this.props.apiKeys.create({ name: this.name });
this.name = "";
} catch (error) {
this.props.ui.showToast(error.message, { type: "error" });
}
};
render() {
const { apiKeys } = this.props;
const hasApiKeys = apiKeys.orderedData.length > 0;
return (
<Scene title="API Tokens" icon={<CodeIcon color="currentColor" />}>
<Heading>API Tokens</Heading>
<HelpText>
You can create an unlimited amount of personal tokens to authenticate
with the API. For more details about the API take a look at the{" "}
<a href="https://www.getoutline.com/developers">
developer documentation
</a>
.
</HelpText>
{hasApiKeys && (
<List>
{apiKeys.orderedData.map((token) => (
<TokenListItem
key={token.id}
token={token}
onDelete={token.delete}
return (
<Scene
title={t("API Tokens")}
icon={<CodeIcon color="currentColor" />}
actions={
<>
{can.createApiKey && (
<Action>
<Button
type="submit"
value={`${t("New token")}`}
onClick={handleNewModalOpen}
/>
))}
</List>
)}
</Action>
)}
</>
}
>
<Heading>{t("API Tokens")}</Heading>
<HelpText>
<Trans
defaults="You can create an unlimited amount of personal tokens to authenticate
with the API. Tokens have the same permissions as your user account.
For more details see the <em>developer documentation</em>."
components={{
em: (
<a href="https://www.getoutline.com/developers" target="_blank" />
),
}}
/>
</HelpText>
<form onSubmit={this.handleSubmit}>
<Input
onChange={this.handleUpdate}
placeholder="Token label (eg. development)"
value={this.name}
required
/>
<Button
type="submit"
value="Create Token"
disabled={apiKeys.isSaving}
/>
</form>
</Scene>
);
}
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<Subheading sticky>{t("Tokens")}</Subheading>}
renderItem={(token) => (
<TokenListItem key={token.id} token={token} onDelete={token.delete} />
)}
/>
<Modal
title={t("Create a token")}
onRequestClose={handleNewModalClose}
isOpen={newModalOpen}
>
<APITokenNew onSubmit={handleNewModalClose} />
</Modal>
</Scene>
);
}
export default inject("apiKeys", "ui")(Tokens);
export default observer(Tokens);
@@ -4,17 +4,20 @@ import ApiKey from "models/ApiKey";
import Button from "components/Button";
import ListItem from "components/List/Item";
type Props = {
type Props = {|
token: ApiKey,
onDelete: (tokenId: string) => Promise<void>,
};
|};
const TokenListItem = ({ token, onDelete }: Props) => {
return (
<ListItem
key={token.id}
title={token.name}
subtitle={<code>{token.secret}</code>}
title={
<>
{token.name} <code>{token.secret}</code>
</>
}
actions={
<Button onClick={() => onDelete(token.id)} neutral>
Revoke
+1 -1
View File
@@ -52,7 +52,7 @@ function UserProfile(props: Props) {
? t("Joined")
: t("Invited")}{" "}
{t("{{ time }} ago.", {
time: formatDistanceToNow(new Date(user.createdAt)),
time: formatDistanceToNow(Date.parse(user.createdAt)),
})}
{user.isAdmin && (
<StyledBadge primary={user.isAdmin}>{t("Admin")}</StyledBadge>
-26
View File
@@ -34,32 +34,6 @@ 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
+3 -3
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);
@@ -616,8 +616,8 @@ export default class DocumentsStore extends BaseStore<Document> {
}
@action
async delete(document: Document) {
await super.delete(document);
async delete(document: Document, options?: {| permanent: boolean |}) {
await super.delete(document, options);
// check to see if we have any shares related to this document already
// loaded in local state. If so we can go ahead and remove those too.
+9
View File
@@ -0,0 +1,9 @@
// @flow
/* eslint-disable */
import localStorage from '../../__mocks__/localStorage';
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
Enzyme.configure({ adapter: new Adapter() });
global.localStorage = localStorage;
+2
View File
@@ -0,0 +1,2 @@
// @flow
export const runAllPromises = () => new Promise<void>(setImmediate);
-377
View File
@@ -1,377 +0,0 @@
// 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
@@ -1,39 +0,0 @@
// 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
@@ -1,68 +0,0 @@
// 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
@@ -1,67 +0,0 @@
// 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
@@ -1,430 +0,0 @@
// 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'>;
}
+17 -42
View File
@@ -13,6 +13,7 @@
"dev": "nodemon --exec \"yarn build:server && yarn build:i18n && node --inspect=0.0.0.0 build/server/index.js\" -e js --ignore build/ --ignore app/",
"lint": "eslint app server shared",
"deploy": "git push heroku master",
"postinstall": "yarn yarn-deduplicate yarn.lock",
"heroku-postbuild": "yarn build && yarn db:migrate",
"sequelize:migrate": "sequelize db:migrate",
"db:create-migration": "sequelize migration:create",
@@ -20,7 +21,7 @@
"db:rollback": "sequelize db:migrate:undo",
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
"test": "yarn test:app && yarn test:server",
"test:app": "jest",
"test:app": "jest --config=app/.jestconfig.json --runInBand --forceExit",
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
"test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll"
},
@@ -28,33 +29,6 @@
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/outline"
},
"jest": {
"testURL": "http://localhost",
"verbose": false,
"roots": [
"app",
"shared"
],
"moduleNameMapper": {
"^shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleFileExtensions": [
"js",
"jsx",
"json"
],
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"app"
],
"setupFiles": [
"<rootDir>/setupJest.js",
"<rootDir>/__mocks__/window.js"
]
},
"engines": {
"node": ">= 12 <=16"
},
@@ -78,7 +52,6 @@
"@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",
@@ -92,6 +65,7 @@
"compressorjs": "^1.0.7",
"copy-to-clipboard": "^3.3.1",
"core-js": "^3.10.2",
"datadog-metrics": "^0.9.3",
"date-fns": "2.22.1",
"dd-trace": "^0.32.2",
"debug": "^4.1.1",
@@ -131,12 +105,12 @@
"koa-sendfile": "2.0.0",
"koa-sslify": "2.1.2",
"koa-static": "^4.0.1",
"lib0": "^0.2.34",
"lodash": "^4.17.19",
"mammoth": "^1.4.16",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
"natural-sort": "^1.0.0",
"node-htmldiff": "^0.9.3",
"nodemailer": "^6.4.16",
"outline-icons": "^1.27.0",
"oy-vey": "^0.10.0",
@@ -146,12 +120,12 @@
"pg": "^8.5.1",
"pg-hstore": "^2.3.3",
"polished": "3.6.5",
"query-string": "^4.3.4",
"query-string": "^7.0.1",
"quoted-printable": "^1.0.1",
"randomstring": "1.1.5",
"raw-loader": "^0.5.1",
"react": "^17.0.2",
"react-autosize-textarea": "^6.0.0",
"react-autosize-textarea": "^7.1.0",
"react-avatar-editor": "^11.1.0",
"react-color": "^2.17.3",
"react-dnd": "^14.0.1",
@@ -168,9 +142,9 @@
"react-virtualized-auto-sizer": "^1.0.5",
"react-waypoint": "^10.1.0",
"react-window": "^1.8.6",
"reakit": "^1.3.6",
"reakit": "^1.3.8",
"regenerator-runtime": "^0.13.7",
"rich-markdown-editor": "^11.10.0",
"rich-markdown-editor": "^11.13.0",
"semver": "^7.3.2",
"sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0",
@@ -179,7 +153,7 @@
"slate-md-serializer": "5.5.4",
"slug": "^4.0.4",
"smooth-scroll-into-view-if-needed": "^1.1.29",
"socket.io": "^2.3.0",
"socket.io": "^2.4.0",
"socket.io-redis": "^5.4.0",
"socketio-auth": "^0.1.1",
"string-replace-to-array": "^1.0.3",
@@ -191,10 +165,7 @@
"turndown": "^6.0.0",
"utf8": "^2.1.0",
"uuid": "^8.3.2",
"validator": "5.2.0",
"y-indexeddb": "^9.0.5",
"y-protocols": "^1.0.1",
"yjs": "^13.4.4"
"validator": "5.2.0"
},
"devDependencies": {
"@babel/cli": "^7.10.5",
@@ -204,6 +175,8 @@
"babel-jest": "^26.2.2",
"babel-loader": "^8.1.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.6",
"eslint": "^7.6.0",
"eslint-config-react-app": "3.0.6",
"eslint-plugin-flowtype": "^5.2.0",
@@ -229,11 +202,13 @@
"webpack-cli": "^3.3.12",
"webpack-manifest-plugin": "^3.0.0",
"webpack-pwa-manifest": "^4.3.0",
"workbox-webpack-plugin": "^6.1.0"
"workbox-webpack-plugin": "^6.1.0",
"yarn-deduplicate": "^3.1.0"
},
"resolutions": {
"prosemirror-view": "1.18.1",
"dot-prop": "^5.2.0",
"js-yaml": "^3.13.1"
},
"version": "0.56.0"
}
"version": "0.57.0"
}
+1 -1
View File
@@ -7,7 +7,7 @@
],
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"./server/test/helper.js"
"./server/test/setup.js"
],
"testEnvironment": "node"
}
+4 -8
View File
@@ -3,8 +3,7 @@
exports[`#users.activate should activate a suspended user 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
@@ -56,8 +55,7 @@ Object {
exports[`#users.demote should demote an admin 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
@@ -177,8 +175,7 @@ Object {
exports[`#users.promote should promote a new admin 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
@@ -239,8 +236,7 @@ Object {
exports[`#users.suspend should suspend an user 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
+97 -61
View File
@@ -5,6 +5,7 @@ import { subtractDate } from "../../shared/utils/date";
import documentCreator from "../commands/documentCreator";
import documentImporter from "../commands/documentImporter";
import documentMover from "../commands/documentMover";
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
import env from "../env";
import {
NotFoundError,
@@ -999,6 +1000,7 @@ 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;
@@ -1010,7 +1012,6 @@ router.post("documents.update", auth(), async (ctx) => {
}
const previousTitle = document.title;
const willPublish = publish && !document.published;
// Update document
if (title) document.title = title;
@@ -1025,61 +1026,67 @@ router.post("documents.update", auth(), async (ctx) => {
document.lastModifiedById = user.id;
const { collection } = document;
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;
}
let transaction;
try {
transaction = await sequelize.transaction();
if (publish) {
await document.publish(user.id, { transaction });
} 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,
});
await document.save({ autosave, transaction });
}
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,
});
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
}
document.updatedBy = user;
document.collection = collection;
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,
});
}
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]),
@@ -1168,24 +1175,53 @@ router.post("documents.archive", auth(), async (ctx) => {
});
router.post("documents.delete", auth(), async (ctx) => {
const { id } = ctx.body;
const { id, permanent } = ctx.body;
ctx.assertPresent(id, "id is required");
const user = ctx.state.user;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "delete", document);
if (permanent) {
const document = await Document.findByPk(id, {
userId: user.id,
paranoid: false,
});
authorize(user, "permanentDelete", document);
await document.delete(user.id);
await Document.update(
{ parentDocumentId: null },
{
where: {
parentDocumentId: document.id,
},
paranoid: false,
}
);
await Event.create({
name: "documents.delete",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
await documentPermanentDeleter([document]);
await Event.create({
name: "documents.permanent_delete",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
} else {
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "delete", document);
await document.delete(user.id);
await Event.create({
name: "documents.delete",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
}
ctx.body = {
success: true,
+35
View File
@@ -984,6 +984,21 @@ describe("#documents.search", () => {
expect(body.data.length).toEqual(0);
});
it("should not error when search term is very long", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.search", {
body: {
token: user.getJwtToken(),
query:
"much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much longer search term",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should return draft documents created by user if chosen", async () => {
const { user } = await seed();
const document = await buildDocument({
@@ -2186,6 +2201,26 @@ describe("#documents.delete", () => {
expect(body.success).toEqual(true);
});
it("should allow permanently deleting a document", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
await server.post("/api/documents.delete", {
body: { token: user.getJwtToken(), id: document.id },
});
const res = await server.post("/api/documents.delete", {
body: { token: user.getJwtToken(), id: document.id, permanent: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
});
it("should allow deleting document without collection", async () => {
const { user, document, collection } = await seed();
+7 -4
View File
@@ -16,6 +16,7 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
let {
sort = "createdAt",
actorId,
documentId,
collectionId,
direction,
name,
@@ -31,10 +32,12 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
if (actorId) {
ctx.assertUuid(actorId, "actorId must be a UUID");
where = {
...where,
actorId,
};
where = { ...where, actorId };
}
if (documentId) {
ctx.assertUuid(documentId, "documentId must be a UUID");
where = { ...where, documentId };
}
if (collectionId) {
+49 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import app from "../app";
import { buildEvent } from "../test/factories";
import { buildEvent, buildUser } from "../test/factories";
import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback());
@@ -101,6 +101,54 @@ describe("#events.list", () => {
expect(body.data[0].id).toEqual(auditEvent.id);
});
it("should allow filtering by documentId", async () => {
const { user, admin, document, collection } = await seed();
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: admin.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it("should not return events for documentId without authorization", async () => {
const { user, document, collection } = await seed();
const actor = await buildUser();
await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: actor.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should allow filtering by event name", async () => {
const { user, admin, document, collection } = await seed();
-5
View File
@@ -17,7 +17,6 @@ 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);
@@ -31,10 +30,6 @@ 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();
+6 -51
View File
@@ -2,10 +2,10 @@
import { subDays } from "date-fns";
import debug from "debug";
import Router from "koa-router";
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
import { AuthenticationError } from "../errors";
import { Document, Attachment } from "../models";
import { Op, sequelize } from "../sequelize";
import parseAttachmentIds from "../utils/parseAttachmentIds";
import { Document } from "../models";
import { Op } from "../sequelize";
const router = new Router();
const log = debug("utils");
@@ -20,7 +20,7 @@ router.post("utils.gc", async (ctx) => {
log(`Permanently destroying upto ${limit} documents older than 30 days…`);
const documents = await Document.scope("withUnpublished").findAll({
attributes: ["id", "teamId", "text"],
attributes: ["id", "teamId", "text", "deletedAt"],
where: {
deletedAt: {
[Op.lt]: subDays(new Date(), 30),
@@ -30,54 +30,9 @@ router.post("utils.gc", async (ctx) => {
limit,
});
const query = `
SELECT COUNT(id)
FROM documents
WHERE "searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"id" != :documentId
`;
const countDeletedDocument = await documentPermanentDeleter(documents);
for (const document of documents) {
const attachmentIds = parseAttachmentIds(document.text);
for (const attachmentId of attachmentIds) {
const [{ count }] = await sequelize.query(query, {
type: sequelize.QueryTypes.SELECT,
replacements: {
documentId: document.id,
teamId: document.teamId,
query: attachmentId,
},
});
if (parseInt(count) === 0) {
const attachment = await Attachment.findOne({
where: {
teamId: document.teamId,
id: attachmentId,
},
});
if (attachment) {
await attachment.destroy();
log(`Attachment ${attachmentId} deleted`);
} else {
log(`Unknown attachment ${attachmentId} ignored`);
}
}
}
}
await Document.scope("withUnpublished").destroy({
where: {
id: documents.map((document) => document.id),
},
force: true,
});
log(`Destroyed ${documents.length} documents`);
log(`Destroyed ${countDeletedDocument} documents`);
ctx.body = {
success: true,
+2 -90
View File
@@ -2,8 +2,8 @@
import { subDays } from "date-fns";
import TestServer from "fetch-test-server";
import app from "../app";
import { Attachment, Document } from "../models";
import { buildAttachment, buildDocument } from "../test/factories";
import { Document } from "../models";
import { buildDocument } from "../test/factories";
import { flushdb } from "../test/support";
const server = new TestServer(app.callback());
@@ -67,94 +67,6 @@ describe("#utils.gc", () => {
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should destroy attachments no longer referenced", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should handle unknown attachment ids", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
// remove attachment so it no longer exists in the database, this is also
// representative of a corrupt attachment id in the doc or the regex returning
// an incorrect string
await attachment.destroy({ force: true });
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should not destroy attachments referenced in other documents", async () => {
const document1 = await buildDocument();
const document = await buildDocument({
teamId: document1.teamId,
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document1.teamId,
documentId: document.id,
});
document1.text = `![text](${attachment.redirectUrl})`;
await document1.save();
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
expect(await Attachment.count()).toEqual(1);
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
});
it("should destroy draft documents deleted more than 30 days ago", async () => {
await buildDocument({
publishedAt: undefined,
+46 -6
View File
@@ -2,12 +2,15 @@
import { subMinutes } from "date-fns";
import Router from "koa-router";
import { find } from "lodash";
import { parseDomain, isCustomSubdomain } from "../../../shared/utils/domains";
import { AuthorizationError } from "../../errors";
import mailer, { sendEmail } from "../../mailer";
import errorHandling from "../../middlewares/errorHandling";
import methodOverride from "../../middlewares/methodOverride";
import validation from "../../middlewares/validation";
import { User, Team } from "../../models";
import { signIn } from "../../utils/authentication";
import { isCustomDomain } from "../../utils/domains";
import { getUserForEmailSigninToken } from "../../utils/jwt";
const router = new Router();
@@ -20,19 +23,56 @@ export const config = {
router.use(methodOverride());
router.use(validation());
router.post("email", async (ctx) => {
router.post("email", errorHandling(), async (ctx) => {
const { email } = ctx.body;
ctx.assertEmail(email, "email is required");
const user = await User.scope("withAuthentications").findOne({
const users = await User.scope("withAuthentications").findAll({
where: { email: email.toLowerCase() },
});
if (user) {
const team = await Team.scope("withAuthenticationProviders").findByPk(
user.teamId
);
if (users.length) {
let team;
if (isCustomDomain(ctx.request.hostname)) {
team = await Team.scope("withAuthenticationProviders").findOne({
where: { domain: ctx.request.hostname },
});
}
if (
process.env.SUBDOMAINS_ENABLED === "true" &&
isCustomSubdomain(ctx.request.hostname) &&
!isCustomDomain(ctx.request.hostname)
) {
const domain = parseDomain(ctx.request.hostname);
const subdomain = domain ? domain.subdomain : undefined;
team = await Team.scope("withAuthenticationProviders").findOne({
where: { subdomain },
});
}
// If there are multiple users with this email address then give precedence
// to the one that is active on this subdomain/domain (if any)
let user = users.find((user) => team && user.teamId === team.id);
// A user was found for the email address, but they don't belong to the team
// that this subdomain belongs to, we load their team and allow the logic to
// continue
if (!user) {
user = users[0];
team = await Team.scope("withAuthenticationProviders").findByPk(
user.teamId
);
}
if (!team) {
team = await Team.scope("withAuthenticationProviders").findByPk(
user.teamId
);
}
if (!team) {
ctx.redirect(`/?notice=auth-error`);
return;
+177
View File
@@ -0,0 +1,177 @@
// @flow
import TestServer from "fetch-test-server";
import app from "../../app";
import mailer from "../../mailer";
import { buildUser, buildGuestUser, buildTeam } from "../../test/factories";
import { flushdb } from "../../test/support";
const server = new TestServer(app.callback());
jest.mock("../../mailer");
beforeEach(async () => {
await flushdb();
// $FlowFixMe does not understand Jest mocks
mailer.signin.mockReset();
});
afterAll(() => server.close());
describe("email", () => {
it("should require email param", async () => {
const res = await server.post("/auth/email", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.error).toEqual("validation_error");
expect(body.ok).toEqual(false);
});
it("should respond with redirect location when user is SSO enabled", async () => {
const user = await buildUser();
const res = await server.post("/auth/email", {
body: { email: user.email },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.signin).not.toHaveBeenCalled();
});
it("should respond with redirect location when user is SSO enabled on another subdomain", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const user = await buildUser();
await buildTeam({
subdomain: "example",
});
const res = await server.post("/auth/email", {
body: { email: user.email },
headers: { host: "example.localoutline.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.signin).not.toHaveBeenCalled();
});
it("should respond with success when user is not SSO enabled", async () => {
const user = await buildGuestUser();
const res = await server.post("/auth/email", {
body: { email: user.email },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.signin).toHaveBeenCalled();
});
it("should respond with success regardless of whether successful to prevent crawling email logins", async () => {
const res = await server.post("/auth/email", {
body: { email: "user@example.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.signin).not.toHaveBeenCalled();
});
describe("with multiple users matching email", () => {
it("should default to current subdomain with SSO", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const email = "sso-user@example.org";
const team = await buildTeam({
subdomain: "example",
});
await buildGuestUser({ email });
await buildUser({ email, teamId: team.id });
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "example.localoutline.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.signin).not.toHaveBeenCalled();
});
it("should default to current subdomain with guest email", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const email = "guest-user@example.org";
const team = await buildTeam({
subdomain: "example",
});
await buildUser({ email });
await buildGuestUser({ email, teamId: team.id });
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "example.localoutline.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.signin).toHaveBeenCalled();
});
it("should default to custom domain with SSO", async () => {
const email = "sso-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
});
await buildGuestUser({ email });
await buildUser({ email, teamId: team.id });
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "docs.mycompany.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.signin).not.toHaveBeenCalled();
});
it("should default to custom domain with guest email", async () => {
const email = "guest-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
});
await buildUser({ email });
await buildGuestUser({ email, teamId: team.id });
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "docs.mycompany.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.signin).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,64 @@
// @flow
import debug from "debug";
import { Document, Attachment } from "../models";
import { sequelize } from "../sequelize";
import parseAttachmentIds from "../utils/parseAttachmentIds";
const log = debug("commands");
export async function documentPermanentDeleter(documents: Document[]) {
const activeDocument = documents.find((doc) => !doc.deletedAt);
if (activeDocument) {
throw new Error(
`Cannot permanently delete ${activeDocument.id} document. Please delete it and try again.`
);
}
const query = `
SELECT COUNT(id)
FROM documents
WHERE "searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"id" != :documentId
`;
for (const document of documents) {
const attachmentIds = parseAttachmentIds(document.text);
for (const attachmentId of attachmentIds) {
const [{ count }] = await sequelize.query(query, {
type: sequelize.QueryTypes.SELECT,
replacements: {
documentId: document.id,
teamId: document.teamId,
query: attachmentId,
},
});
if (parseInt(count) === 0) {
const attachment = await Attachment.findOne({
where: {
teamId: document.teamId,
id: attachmentId,
},
});
if (attachment) {
await attachment.destroy();
log(`Attachment ${attachmentId} deleted`);
} else {
log(`Unknown attachment ${attachmentId} ignored`);
}
}
}
}
return Document.scope("withUnpublished").destroy({
where: {
id: documents.map((document) => document.id),
},
force: true,
});
}
@@ -0,0 +1,123 @@
// @flow
import { subDays } from "date-fns";
import { Attachment, Document } from "../models";
import { buildAttachment, buildDocument } from "../test/factories";
import { flushdb } from "../test/support";
import { documentPermanentDeleter } from "./documentPermanentDeleter";
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => flushdb());
describe("documentPermanentDeleter", () => {
it("should destroy documents", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should error when trying to destroy undeleted documents", async () => {
const document = await buildDocument({
publishedAt: new Date(),
});
let error;
try {
await documentPermanentDeleter([document]);
} catch (err) {
error = err.message;
}
expect(error).toEqual(
`Cannot permanently delete ${document.id} document. Please delete it and try again.`
);
});
it("should destroy attachments no longer referenced", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should handle unknown attachment ids", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
// remove attachment so it no longer exists in the database, this is also
// representative of a corrupt attachment id in the doc or the regex returning
// an incorrect string
await attachment.destroy({ force: true });
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should not destroy attachments referenced in other documents", async () => {
const document1 = await buildDocument();
const document = await buildDocument({
teamId: document1.teamId,
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document1.teamId,
documentId: document.id,
});
document1.text = `![text](${attachment.redirectUrl})`;
await document1.save();
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
expect(await Attachment.count()).toEqual(1);
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
});
});
-67
View File
@@ -1,67 +0,0 @@
// @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 -2
View File
@@ -41,11 +41,13 @@ export const CollectionNotificationEmail = ({
<Body>
<Heading>{collection.name}</Heading>
<p>
{actor.name} {eventName} the collection "{collection.name}".
{actor.name} {eventName} the collection {collection.name}.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${process.env.URL}${collection.url}`}>
<Button
href={`${process.env.URL}${collection.url}?ref=notification-email`}
>
Open Collection
</Button>
</p>
+225 -6
View File
@@ -1,8 +1,10 @@
// @flow
import * as React from "react";
import theme from "../../shared/styles/theme";
import { User, Document, Team, Collection } from "../models";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
@@ -15,6 +17,7 @@ export type Props = {
document: Document,
collection: Collection,
eventName: string,
summary: string,
unsubscribeUrl: string,
};
@@ -38,26 +41,34 @@ export const DocumentNotificationEmail = ({
document,
collection,
eventName = "published",
summary,
unsubscribeUrl,
}: Props) => {
const link = `${team.url}${document.url}?ref=notification-email`;
return (
<EmailTemplate>
<Header />
<Body>
<Heading>
"{document.title}" {eventName}
{document.title} {eventName}
</Heading>
<p>
{actor.name} {eventName} the document "{document.title}", in the{" "}
{collection.name} collection.
</p>
<hr />
<EmptySpace height={10} />
<p>{document.getSummary()}</p>
<EmptySpace height={10} />
{summary && (
<>
<EmptySpace height={20} />
<Diff href={link}>
<div dangerouslySetInnerHTML={{ __html: summary }} />
</Diff>
<EmptySpace height={20} />
</>
)}
<p>
<Button href={`${team.url}${document.url}`}>Open Document</Button>
<Button href={link}>Open Document</Button>
</p>
</Body>
@@ -65,3 +76,211 @@ export const DocumentNotificationEmail = ({
</EmailTemplate>
);
};
export const css = `
font-family: ${theme.fontFamily};
font-weight: ${theme.fontWeight};
font-size: 1em;
line-height: 1.7em;
pre {
white-space: pre-wrap;
}
img {
text-align: center;
max-width: 100%;
max-height: 75vh;
clear: both;
}
img.image-right-50 {
float: right;
width: 50%;
margin-left: 2em;
margin-bottom: 1em;
clear: initial;
}
img.image-left-50 {
float: left;
width: 50%;
margin-right: 2em;
margin-bottom: 1em;
clear: initial;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 1em 0 0.5em;
font-weight: 500;
}
.notice {
display: flex;
align-items: center;
background: ${theme.noticeInfoBackground};
color: ${theme.noticeInfoText};
border-radius: 4px;
padding: 8px 16px;
margin: 8px 0;
}
.notice-tip {
background: ${theme.noticeTipBackground};
color: ${theme.noticeTipText};
}
.notice-warning {
background: ${theme.noticeWarningBackground};
color: ${theme.noticeWarningText};
}
b,
strong {
font-weight: 600;
}
p {
margin: 0;
}
a {
color: ${theme.link};
}
ins {
background-color: #128a2929;
text-decoration: none;
}
del {
background-color: ${theme.slateLight};
color: ${theme.slate};
text-decoration: strikethrough;
}
hr {
position: relative;
height: 1em;
border: 0;
}
hr:before {
content: "";
display: block;
position: absolute;
border-top: 1px solid ${theme.horizontalRule};
top: 0.5em;
left: 0;
right: 0;
}
hr.page-break {
page-break-after: always;
}
hr.page-break:before {
border-top: 1px dashed ${theme.horizontalRule};
}
code {
border-radius: 4px;
border: 1px solid ${theme.codeBorder};
padding: 3px 4px;
font-family: ${theme.fontFamilyMono};
font-size: 85%;
}
mark {
border-radius: 1px;
color: ${theme.textHighlightForeground};
background: ${theme.textHighlight};
a {
color: ${theme.textHighlightForeground};
}
}
ul {
padding-left: 0;
}
.checkbox-list-item {
list-style: none;
padding: 4px 0;
margin: 0;
}
.checkbox {
font-size: 0;
display: block;
float: left;
white-space: nowrap;
width: 12px;
height: 12px;
margin-top: 2px;
margin-right: 8px;
border: 1px solid ${theme.textSecondary};
border-radius: 3px;
}
pre {
display: block;
overflow-x: auto;
padding: 0.75em 1em;
line-height: 1.4em;
position: relative;
background: ${theme.codeBackground};
border-radius: 4px;
border: 1px solid ${theme.codeBorder};
-webkit-font-smoothing: initial;
font-family: ${theme.fontFamilyMono};
font-size: 13px;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
margin: 0;
code {
font-size: 13px;
background: none;
padding: 0;
border: 0;
}
}
table {
width: 100%;
border-collapse: collapse;
border-radius: 4px;
margin-top: 1em;
box-sizing: border-box;
* {
box-sizing: border-box;
}
tr {
position: relative;
border-bottom: 1px solid ${theme.tableDivider};
}
td,
th {
position: relative;
vertical-align: top;
border: 1px solid ${theme.tableDivider};
position: relative;
padding: 4px 8px;
min-width: 100px;
}
}
`;
+1 -1
View File
@@ -47,7 +47,7 @@ export const InviteEmail = ({
</p>
<EmptySpace height={10} />
<p>
<Button href={teamUrl}>Join now</Button>
<Button href={`${teamUrl}?ref=invite-email`}>Join now</Button>
</p>
</Body>
+3 -1
View File
@@ -43,7 +43,9 @@ export const WelcomeEmail = ({ teamUrl }: Props) => {
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
<Button href={`${teamUrl}/home?ref=welcome-email`}>
View my dashboard
</Button>
</p>
</Body>
+25
View File
@@ -0,0 +1,25 @@
// @flow
import * as React from "react";
import theme from "../../../shared/styles/theme";
type Props = {|
children: React.Node,
href?: string,
|};
export default ({ children, ...rest }: Props) => {
const style = {
borderRadius: "4px",
background: theme.secondaryBackground,
padding: ".5em 1em",
color: theme.text,
display: "block",
textDecoration: "none",
};
return (
<a width="100%" style={style} {...rest}>
{children}
</a>
);
};
+2 -2
View File
@@ -3,9 +3,9 @@ import { Table, TBody, TR, TD } from "oy-vey";
import * as React from "react";
import theme from "../../../shared/styles/theme";
type Props = {
type Props = {|
children: React.Node,
};
|};
export default (props: Props) => (
<Table width="550" padding="40">
+3
View File
@@ -35,6 +35,7 @@ export type DocumentEvent =
name: | "documents.create" // eslint-disable-line
| "documents.publish"
| "documents.delete"
| "documents.permanent_delete"
| "documents.pin"
| "documents.unpin"
| "documents.archive"
@@ -99,6 +100,8 @@ export type RevisionEvent = {
documentId: string,
collectionId: string,
teamId: string,
actorId: string,
modelId: string,
};
export type CollectionImportEvent = {
+23 -12
View File
@@ -13,6 +13,7 @@ import {
type Props as DocumentNotificationEmailT,
DocumentNotificationEmail,
documentNotificationEmailText,
css as documentNotificationEmailCSS,
} from "./emails/DocumentNotificationEmail";
import { ExportEmail, exportEmailText } from "./emails/ExportEmail";
import {
@@ -146,8 +147,9 @@ export class Mailer {
this.sendMail({
to: opts.to,
title: `${opts.document.title}${opts.eventName}`,
previewText: `${opts.actor.name} ${opts.eventName} a new document`,
previewText: `${opts.actor.name} ${opts.eventName} a document`,
html: <DocumentNotificationEmail {...opts} />,
headCSS: documentNotificationEmailCSS,
text: documentNotificationEmailText(opts),
});
};
@@ -178,6 +180,10 @@ export class Mailer {
? process.env.SMTP_SECURE === "true"
: process.env.NODE_ENV === "production",
auth: undefined,
tls:
"SMTP_TLS_CIPHERS" in process.env
? { ciphers: process.env.SMTP_TLS_CIPHERS }
: undefined,
};
if (process.env.SMTP_USERNAME) {
@@ -193,19 +199,24 @@ export class Mailer {
if (useTestEmailService) {
log("SMTP_USERNAME not provided, generating test account…");
let testAccount = await nodemailer.createTestAccount();
const smtpConfig = {
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
};
try {
let testAccount = await nodemailer.createTestAccount();
this.transporter = nodemailer.createTransport(smtpConfig);
const smtpConfig = {
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
};
this.transporter = nodemailer.createTransport(smtpConfig);
} catch (err) {
log(`Could not generate test account: ${err.message}`);
}
}
}
}
+96 -168
View File
@@ -1,21 +1,18 @@
// @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 { Team, Document, Collection, View } from "./models";
import * as multiplayer from "./multiplayer";
import { Document, Collection, View } from "./models";
import policy from "./policies";
import { client, subscriber } from "./redis";
import { getUserForJWT } from "./utils/jwt";
import * as metrics from "./utils/metrics";
import { checkMigrations } from "./utils/startup";
const server = http.createServer(app.callback());
const log = debug("server");
let io;
const { can } = policy;
@@ -33,6 +30,10 @@ io.adapter(
})
);
io.origins((_, callback) => {
callback(null, true);
});
io.of("/").adapter.on("error", (err) => {
if (err.name === "MaxRetriesPerRequestError") {
console.error(`Redis error: ${err.message}. Shutting down now.`);
@@ -42,6 +43,22 @@ io.of("/").adapter.on("error", (err) => {
}
});
io.on("connection", (socket) => {
metrics.increment("websockets.connected");
metrics.gaugePerInstance(
"websockets.count",
socket.client.conn.server.clientsCount
);
socket.on("disconnect", () => {
metrics.increment("websockets.disconnected");
metrics.gaugePerInstance(
"websockets.count",
socket.client.conn.server.clientsCount
);
});
});
SocketAuth(io, {
authenticate: async (socket, data, callback) => {
const { token } = data;
@@ -60,7 +77,6 @@ SocketAuth(io, {
}
},
postAuthenticate: async (socket, data) => {
log(`postAuthenticate ${socket.id}`);
const { user } = socket.client;
// the rooms associated with the current team
@@ -77,121 +93,41 @@ SocketAuth(io, {
// join all of the rooms at once
socket.join(rooms);
},
});
// 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);
};
// 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);
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);
if (can(user, "read", collection)) {
socket.join(`collection-${event.collectionId}`, () => {
metrics.increment("websockets.collections.join");
});
}
}
);
});
// allow the client to request to join rooms
socket.on("join", async (event) => {
if (!socket.auth) {
return;
}
// 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,
});
log("join", event.documentId, socket.id);
const { user } = socket.client;
if (can(user, "read", document)) {
const room = `document-${event.documentId}`;
// 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);
await View.touch(event.documentId, user.id, event.isEditing);
const editing = await View.findRecentlyEditingByDocument(
event.documentId
);
socket.join(room, () => {
metrics.increment("websockets.documents.join");
// let everyone else in the room know that a new user joined
io.to(room).emit("user.join", {
userId: user.id,
@@ -200,11 +136,11 @@ io.on("connection", (socket) => {
});
// let this user know who else is already present in the room
io.in(room).clients(async (err, socketIds) => {
io.in(room).clients(async (err, sockets) => {
if (err) {
if (process.env.SENTRY_DSN) {
Sentry.withScope(function (scope) {
scope.setExtra("clients", socketIds);
scope.setExtra("clients", sockets);
Sentry.captureException(err);
});
} else {
@@ -217,7 +153,7 @@ io.on("connection", (socket) => {
// need to make sure that only unique userIds are returned. A Map
// makes this easy.
let userIds = new Map();
for (const socketId of socketIds) {
for (const socketId of sockets) {
const userId = await client.hget(socketId, "userId");
userIds.set(userId, userId);
}
@@ -230,71 +166,63 @@ io.on("connection", (socket) => {
});
}
}
}
});
});
// allow the client to request to leave rooms
socket.on("leave", (event) => {
if (!socket.auth) {
return;
}
if (event.collectionId) {
socket.leave(`collection-${event.collectionId}`);
}
if (event.documentId) {
const room = `document-${event.documentId}`;
const userId = socket.client.user.id;
socket.leave(room, () => {
io.to(room).emit("user.leave", {
userId,
documentId: event.documentId,
// allow the client to request to leave rooms
socket.on("leave", (event) => {
if (event.collectionId) {
socket.leave(`collection-${event.collectionId}`, () => {
metrics.increment("websockets.collections.leave");
});
});
}
if (event.documentId) {
const room = `document-${event.documentId}`;
socket.leave(room, () => {
metrics.increment("websockets.documents.leave");
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,
io.to(room).emit("user.leave", {
userId: user.id,
documentId: event.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;
socket.on("disconnecting", () => {
const rooms = Object.keys(socket.rooms);
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,
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) => {
metrics.increment("websockets.presence");
const room = `document-${event.documentId}`;
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) => {
+9 -1
View File
@@ -9,7 +9,7 @@ export default function createMiddleware(providerName: string) {
return passport.authorize(
providerName,
{ session: false },
async (err, _, result: AccountProvisionerResult) => {
async (err, user, result: AccountProvisionerResult) => {
if (err) {
console.error(err);
@@ -24,6 +24,14 @@ export default function createMiddleware(providerName: string) {
return ctx.redirect(`/?notice=auth-error`);
}
// Passport.js may invoke this callback with err=null and user=null in
// the event that error=access_denied is received from the OAuth server.
// I'm not sure why this exception to the rule exists, but it does:
// https://github.com/jaredhanson/passport-oauth2/blob/e20f26aad60ed54f0e7952928cbb64979ef8da2b/lib/strategy.js#L135
if (!user) {
return ctx.redirect(`/?notice=auth-error`);
}
// Handle errors from Azure which come in the format: message, Trace ID,
// Correlation ID, Timestamp in these two query string parameters.
const { error, error_description } = ctx.request.query;
@@ -1,15 +1,7 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('users', 'email', {
type: Sequelize.STRING,
unique: false,
allowNull: false,
});
await queryInterface.changeColumn('users', 'username', {
type: Sequelize.STRING,
unique: false,
allowNull: false,
});
await queryInterface.removeConstraint('users', 'users_email_key', {})
await queryInterface.removeConstraint('users', 'users_username_key', {})
},
down: async (queryInterface, Sequelize) => {
@@ -1,18 +0,0 @@
'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');
}
};
+5 -2
View File
@@ -395,7 +395,11 @@ Collection.prototype.isChildDocument = function (
let result = false;
const loopChildren = (documents, input) => {
return documents.map((document) => {
if (result) {
return;
}
documents.forEach((document) => {
let parents = [...input];
if (document.id === documentId) {
result = parents.includes(parentDocumentId);
@@ -403,7 +407,6 @@ Collection.prototype.isChildDocument = function (
parents.push(document.id);
loopChildren(document.children, parents);
}
return document;
});
};
-1
View File
@@ -74,7 +74,6 @@ 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
+2
View File
@@ -65,6 +65,7 @@ Event.ACTIVITY_EVENTS = [
"documents.unpin",
"documents.move",
"documents.delete",
"documents.permanent_delete",
"documents.restore",
"users.create",
];
@@ -90,6 +91,7 @@ Event.AUDIT_EVENTS = [
"documents.unpin",
"documents.move",
"documents.delete",
"documents.permanent_delete",
"documents.restore",
"groups.create",
"groups.update",
+3
View File
@@ -15,6 +15,9 @@ const SearchQuery = sequelize.define(
},
query: {
type: DataTypes.STRING,
set(val) {
this.setDataValue("query", val.substring(0, 255));
},
allowNull: false,
},
results: {
-5
View File
@@ -69,11 +69,6 @@ const Team = sequelize.define(
allowNull: false,
defaultValue: true,
},
multiplayerEditor: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
},
{
paranoid: true,
-6
View File
@@ -7,7 +7,6 @@ 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,11 +74,6 @@ const User = sequelize.define(
.digest("hex");
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png`;
},
color() {
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
const idAsNumber = parseInt(idAsHex, 16);
return palette[idAsNumber % palette.length];
},
},
}
);
-96
View File
@@ -1,96 +0,0 @@
// @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
@@ -1,242 +0,0 @@
// @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:
}
}
+15 -3
View File
@@ -101,8 +101,15 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
});
allow(User, "delete", Document, (user, document) => {
// unpublished drafts can always be deleted
if (user.isViewer) return false;
if (document.deletedAt) return false;
// allow deleting document without a collection
if (document.collection && cannot(user, "update", document.collection)) {
return false;
}
// unpublished drafts can always be deleted
if (
!document.deletedAt &&
!document.publishedAt &&
@@ -111,13 +118,18 @@ allow(User, "delete", Document, (user, document) => {
return true;
}
return user.teamId === document.teamId;
});
allow(User, "permanentDelete", Document, (user, document) => {
if (user.isViewer) return false;
if (!document.deletedAt) return false;
// allow deleting document without a collection
if (document.collection && cannot(user, "update", document.collection)) {
return false;
}
if (document.deletedAt) return false;
return user.teamId === document.teamId;
});
@@ -3,7 +3,6 @@
exports[`presents a user 1`] = `
Object {
"avatarUrl": undefined,
"color": undefined,
"createdAt": undefined,
"id": "123",
"isAdmin": undefined,
@@ -17,7 +16,6 @@ Object {
exports[`presents a user without slack data 1`] = `
Object {
"avatarUrl": undefined,
"color": undefined,
"createdAt": undefined,
"id": "123",
"isAdmin": undefined,
-1
View File
@@ -9,7 +9,6 @@ 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,

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