mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35a4dd19e6 | |||
| 750d9ab4c6 | |||
| d40e60675d | |||
| 76169c1bf2 | |||
| 8ed030e2ec | |||
| a55163fb00 | |||
| b05a36d450 | |||
| dde6c3e443 | |||
| e861884b4e | |||
| 4ca2d3776e | |||
| 7c0ddf7efb | |||
| 6842ea4a35 | |||
| 7e0ebc6b4e | |||
| c0a322bc20 | |||
| 769b0225e2 | |||
| 9bcf5b0292 | |||
| 3b4aa02c67 | |||
| 375d658231 | |||
| 70ea77ce01 | |||
| cea1d808d1 | |||
| bea8b85cf9 | |||
| abeccb8a4c | |||
| c8d3d26044 | |||
| ec5a7d79f5 | |||
| 30d31b35ac | |||
| 8abf2436dd | |||
| 4df75bda7b | |||
| 220546c40a | |||
| b96ffe59db | |||
| 2676a7e8cf | |||
| 5e9e4fb028 | |||
| 551b1620e0 | |||
| b8569ed8de | |||
| 8d1a707dd0 | |||
| 9877cf1f4e | |||
| 50fbcd8d85 | |||
| 359d228771 | |||
| 0347620c75 | |||
| cb362511a5 | |||
| 700db463fc | |||
| a28dfa77ee | |||
| d8bc6515dd | |||
| 0776b78e25 | |||
| 4256e7ec87 | |||
| e723124f8f | |||
| c2fbd78622 | |||
| 17cbeab409 | |||
| 37d456a0fb | |||
| 48a0ba0dec | |||
| f454467bf1 | |||
| f21f660543 | |||
| 50637bc7ce | |||
| acb61d5e0c | |||
| 7dcbaa9c5c | |||
| ae1761e517 | |||
| 7166378c32 | |||
| 2719321430 | |||
| a7f2c7edb3 |
+2
-3
@@ -8,7 +8,8 @@
|
||||
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
|
||||
# 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`
|
||||
# in your terminal to generate a random value.
|
||||
SECRET_KEY=generate_a_new_key
|
||||
|
||||
@@ -128,8 +129,6 @@ 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
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
.*/node_modules/react-side-effect/.*
|
||||
.*/node_modules/fbjs/.*
|
||||
.*/node_modules/config-chain/.*
|
||||
.*/node_modules/yjs/.*
|
||||
.*/node_modules/y-prosemirror/.*
|
||||
.*/node_modules/y-protocols/.*
|
||||
.*/node_modules/lib0/.*
|
||||
.*/server/scripts/.*
|
||||
*.test.js
|
||||
|
||||
|
||||
@@ -135,15 +135,6 @@
|
||||
"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
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -29,26 +29,15 @@ const MenuItem = ({
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onClick(ev);
|
||||
}
|
||||
|
||||
if (hide) {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
[onClick, hide]
|
||||
[hide, onClick]
|
||||
);
|
||||
|
||||
// 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}
|
||||
@@ -62,7 +51,6 @@ const MenuItem = ({
|
||||
$toggleable={selected !== undefined}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
|
||||
@@ -83,7 +83,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||
);
|
||||
});
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unneccessary separators
|
||||
@@ -101,11 +101,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
return filterTemplateItems(items).map((item, index) => {
|
||||
return filtered.map((item, index) => {
|
||||
if (item.to) {
|
||||
return (
|
||||
<MenuItem
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function ContextMenu({
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background dir="auto">
|
||||
<Background>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
|
||||
@@ -41,7 +41,7 @@ function replaceResultMarks(tag: string) {
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
function DocumentListItem(props: Props, ref) {
|
||||
function DocumentListItem(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const currentUser = useCurrentUser();
|
||||
@@ -68,8 +68,6 @@ function DocumentListItem(props: Props, ref) {
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={ref}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
@@ -78,12 +76,8 @@ function DocumentListItem(props: Props, ref) {
|
||||
}}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{document.isNew && document.createdBy.id !== currentUser.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
@@ -227,7 +221,6 @@ 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;
|
||||
@@ -258,4 +251,4 @@ const ResultContext = styled(Highlight)`
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(DocumentListItem));
|
||||
export default observer(DocumentListItem);
|
||||
|
||||
@@ -11,7 +11,6 @@ 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;
|
||||
@@ -136,7 +135,7 @@ function DocumentMeta({
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
|
||||
@@ -14,7 +14,6 @@ type Props = {|
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
to?: string,
|
||||
rtl?: boolean,
|
||||
|};
|
||||
|
||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
@@ -63,7 +62,6 @@ 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;
|
||||
|
||||
@@ -56,7 +56,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
timeAgo: formatDistanceToNow(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date()
|
||||
view ? new Date(view.lastViewedAt) : new Date()
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -245,6 +245,53 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
.ProseMirror-yjs-cursor {
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
border-left: 1px solid black;
|
||||
border-right: 1px solid black;
|
||||
height: 1em;
|
||||
word-break: normal;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
right: -8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
> div {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: -1.8em;
|
||||
font-size: 13px;
|
||||
background-color: rgb(250, 129, 0);
|
||||
font-style: normal;
|
||||
line-height: normal;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> div {
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const EditorTooltip = ({ children, ...props }) => (
|
||||
@@ -262,3 +309,17 @@ const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
|
||||
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
|
||||
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
// > .ProseMirror-yjs-cursor:first-child {
|
||||
// margin-top: 16px;
|
||||
// }
|
||||
|
||||
// p:first-child,
|
||||
// h1:first-child,
|
||||
// h2:first-child,
|
||||
// h3:first-child,
|
||||
// h4:first-child,
|
||||
// h5:first-child,
|
||||
// h6:first-child {
|
||||
// margin-top: 16px;
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {|
|
||||
interval?: number,
|
||||
|};
|
||||
|
||||
export default function LoadingEllipsis({ interval = 750 }: Props) {
|
||||
const [step, setStep] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handle = setInterval(() => {
|
||||
setStep((step) => (step === 3 ? 0 : step + 1));
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(handle);
|
||||
}, [interval]);
|
||||
|
||||
return ".".repeat(step);
|
||||
}
|
||||
@@ -1,18 +1,6 @@
|
||||
// @flow
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
enUS,
|
||||
de,
|
||||
fr,
|
||||
es,
|
||||
it,
|
||||
ko,
|
||||
ptBR,
|
||||
pt,
|
||||
zhCN,
|
||||
zhTW,
|
||||
ru,
|
||||
} from "date-fns/locale";
|
||||
import { enUS, de, fr, es, it, ko, ptBR, pt, zhCN, ru } from "date-fns/locale";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useUserLocale from "hooks/useUserLocale";
|
||||
@@ -27,7 +15,6 @@ const locales = {
|
||||
pt_BR: ptBR,
|
||||
pt_PT: pt,
|
||||
zh_CN: zhCN,
|
||||
zh_TW: zhTW,
|
||||
ru_RU: ru,
|
||||
};
|
||||
|
||||
|
||||
@@ -38,24 +38,14 @@ class PaginatedList extends React.Component<Props> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (
|
||||
prevProps.fetch !== this.props.fetch ||
|
||||
!isEqual(prevProps.options, this.props.options)
|
||||
) {
|
||||
this.reset();
|
||||
if (prevProps.fetch !== this.props.fetch) {
|
||||
this.fetchResults();
|
||||
}
|
||||
if (!isEqual(prevProps.options, this.props.options)) {
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// @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,19 +27,16 @@ type Props = {|
|
||||
parentId?: string,
|
||||
|};
|
||||
|
||||
function DocumentLink(
|
||||
{
|
||||
node,
|
||||
canUpdate,
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
}: Props,
|
||||
ref
|
||||
) {
|
||||
function DocumentLink({
|
||||
node,
|
||||
canUpdate,
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
}: Props) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -239,7 +236,6 @@ function DocumentLink(
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
ref={ref}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
<Fade>
|
||||
@@ -293,6 +289,5 @@ const Disclosure = styled(CollapsedIcon)`
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
|
||||
|
||||
const ObservedDocumentLink = observer(DocumentLink);
|
||||
export default ObservedDocumentLink;
|
||||
|
||||
@@ -65,7 +65,6 @@ 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: col-resize;
|
||||
cursor: ew-resize;
|
||||
`;
|
||||
|
||||
export default ResizeBorder;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @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";
|
||||
@@ -30,28 +29,25 @@ type Props = {
|
||||
depth?: number,
|
||||
};
|
||||
|
||||
function SidebarLink(
|
||||
{
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
menu,
|
||||
showActions,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
}: Props,
|
||||
ref
|
||||
) {
|
||||
function SidebarLink({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
menu,
|
||||
showActions,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
}: Props) {
|
||||
const style = React.useMemo(() => {
|
||||
return {
|
||||
paddingLeft: `${(depth || 0) * 16 + 16}px`,
|
||||
@@ -82,7 +78,6 @@ function SidebarLink(
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
className={className}
|
||||
ref={ref}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
@@ -146,8 +141,7 @@ const Link = styled(NavLink)`
|
||||
|
||||
&:focus {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) =>
|
||||
transparentize("0.25", props.theme.sidebarItemBackground)};
|
||||
background: ${(props) => props.theme.black05};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
@@ -178,9 +172,6 @@ const Label = styled.div`
|
||||
width: 100%;
|
||||
max-height: 4.8em;
|
||||
line-height: 1.6;
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
`;
|
||||
|
||||
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
|
||||
export default withRouter(withTheme(SidebarLink));
|
||||
|
||||
@@ -250,10 +250,6 @@ 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) => {
|
||||
|
||||
@@ -17,10 +17,7 @@ export default class Mindmeister extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const chartId =
|
||||
this.props.attrs.matches[4] +
|
||||
(this.props.attrs.matches[5] || "") +
|
||||
(this.props.attrs.matches[6] || "");
|
||||
const chartId = this.props.attrs.matches[4] + this.props.attrs.matches[6];
|
||||
|
||||
return (
|
||||
<Frame
|
||||
|
||||
@@ -11,7 +11,9 @@ import Flex from "components/Flex";
|
||||
const Iframe = (props) => <iframe title="Embed" {...props} />;
|
||||
|
||||
const StyledIframe = styled(Iframe)`
|
||||
border-radius: ${(props) => (props.$withBar ? "3px 3px 0 0" : "3px")};
|
||||
border: 1px solid;
|
||||
border-color: ${(props) => props.theme.embedBorder};
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
display: block;
|
||||
`;
|
||||
|
||||
@@ -68,13 +70,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}
|
||||
@@ -106,11 +108,10 @@ class Frame extends React.Component<PropsWithRef> {
|
||||
}
|
||||
|
||||
const Rounded = styled.div`
|
||||
border: 1px solid ${(props) => props.theme.embedBorder};
|
||||
border-radius: 6px;
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
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`
|
||||
@@ -131,12 +132,11 @@ 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: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ import useStores from "./useStores";
|
||||
|
||||
export default function useCurrentTeam() {
|
||||
const { auth } = useStores();
|
||||
invariant(auth.team, "team required");
|
||||
invariant(auth.team, "Expected to be authenticated");
|
||||
return auth.team;
|
||||
}
|
||||
|
||||
+42
-48
@@ -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, { filterTemplateItems } from "components/ContextMenu/Template";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "hooks/useStores";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
@@ -110,52 +110,6 @@ 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 (
|
||||
<>
|
||||
@@ -180,7 +134,47 @@ function CollectionMenu({
|
||||
onClose={onClose}
|
||||
aria-label={t("Collection")}
|
||||
>
|
||||
<Template {...menu} items={items} />
|
||||
<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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
|
||||
+33
-63
@@ -9,7 +9,6 @@ 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";
|
||||
@@ -62,10 +61,6 @@ 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>();
|
||||
@@ -332,11 +327,6 @@ function DocumentMenu({
|
||||
onClick: () => setShowDeleteModal(true),
|
||||
visible: !!can.delete,
|
||||
},
|
||||
{
|
||||
title: `${t("Permanently delete")}…`,
|
||||
onClick: () => setShowPermanentDeleteModal(true),
|
||||
visible: can.permanentDelete,
|
||||
},
|
||||
{
|
||||
title: `${t("Move")}…`,
|
||||
onClick: () => setShowMoveModal(true),
|
||||
@@ -367,60 +357,40 @@ function DocumentMenu({
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
{can.move && (
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
isOpen={showMoveModal}
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
+23
-21
@@ -58,26 +58,6 @@ 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";
|
||||
@@ -96,6 +76,7 @@ export default class Document extends BaseModel {
|
||||
@computed
|
||||
get isNew(): boolean {
|
||||
return (
|
||||
!!this.publishedAt &&
|
||||
!this.lastViewedAt &&
|
||||
differenceInDays(new Date(), new Date(this.createdAt)) < 14
|
||||
);
|
||||
@@ -251,6 +232,27 @@ export default class Document extends BaseModel {
|
||||
this.injectTemplate = true;
|
||||
};
|
||||
|
||||
@action
|
||||
update = async (options: SaveOptions & { title: string }) => {
|
||||
if (this.isSaving) return this;
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
if (options.lastRevision) {
|
||||
return await this.store.update({
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
lastRevision: options.lastRevision,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error("Attempting to update without a lastRevision");
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
save = async (options: SaveOptions = {}) => {
|
||||
if (this.isSaving) return this;
|
||||
@@ -284,7 +286,7 @@ export default class Document extends BaseModel {
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error("Attempting to update without a lastRevision");
|
||||
throw new Error("Attempting to save without a lastRevision");
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ class Team extends BaseModel {
|
||||
avatarUrl: string;
|
||||
sharing: boolean;
|
||||
documentEmbeds: boolean;
|
||||
multiplayerEditor: boolean;
|
||||
guestSignin: boolean;
|
||||
subdomain: ?string;
|
||||
domain: ?string;
|
||||
|
||||
@@ -8,6 +8,7 @@ class User extends BaseModel {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
color: string;
|
||||
isAdmin: boolean;
|
||||
isViewer: boolean;
|
||||
lastActiveAt: string;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @flow
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import { Extension } from "rich-markdown-editor";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
yUndoPlugin,
|
||||
undo,
|
||||
redo,
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
|
||||
export default class MultiplayerExtension extends Extension {
|
||||
get name() {
|
||||
return "multiplayer";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const { user, provider, doc } = this.options;
|
||||
const type = doc.get("prosemirror", Y.XmlFragment);
|
||||
|
||||
const assignUser = (tr) => {
|
||||
const clientIds = Array.from(doc.store.clients.keys());
|
||||
|
||||
if (
|
||||
tr.local &&
|
||||
tr.changed.size > 0 &&
|
||||
!clientIds.includes(doc.clientID)
|
||||
) {
|
||||
const permanentUserData = new Y.PermanentUserData(doc);
|
||||
permanentUserData.setUserMapping(doc, doc.clientID, user.id);
|
||||
doc.off("afterTransaction", assignUser);
|
||||
}
|
||||
};
|
||||
|
||||
provider.awareness.setLocalStateField("user", {
|
||||
color: user.color,
|
||||
name: user.name,
|
||||
id: user.id,
|
||||
});
|
||||
|
||||
doc.on("afterTransaction", assignUser);
|
||||
|
||||
return [
|
||||
ySyncPlugin(type),
|
||||
yCursorPlugin(provider.awareness),
|
||||
yUndoPlugin(),
|
||||
keymap({
|
||||
"Mod-z": undo,
|
||||
"Mod-y": redo,
|
||||
"Mod-Shift-z": redo,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// Based on example implementation, modified to work with existing sockets
|
||||
// https://github.com/yjs/y-websocket/blob/master/src/y-websocket.js
|
||||
|
||||
// @flow
|
||||
import * as bc from "lib0/broadcastchannel.js";
|
||||
import * as decoding from "lib0/decoding.js";
|
||||
import * as encoding from "lib0/encoding.js";
|
||||
import * as mutex from "lib0/mutex.js";
|
||||
import { Observable } from "lib0/observable.js";
|
||||
import { Socket } from "socket.io-client";
|
||||
import * as awarenessProtocol from "y-protocols/awareness.js";
|
||||
import * as syncProtocol from "y-protocols/sync.js";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
MESSAGE_SYNC,
|
||||
MESSAGE_AWARENESS,
|
||||
MESSAGE_QUERY_AWARENESS,
|
||||
} from "shared/constants";
|
||||
|
||||
const readMessage = (
|
||||
provider: WebsocketProvider,
|
||||
buff: Uint8Array,
|
||||
emitSynced: boolean
|
||||
): encoding.Encoder => {
|
||||
const decoder = decoding.createDecoder(buff);
|
||||
const encoder = encoding.createEncoder();
|
||||
const messageType = decoding.readVarUint(decoder);
|
||||
|
||||
switch (messageType) {
|
||||
case MESSAGE_SYNC: {
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
||||
const syncMessageType = syncProtocol.readSyncMessage(
|
||||
decoder,
|
||||
encoder,
|
||||
provider.doc,
|
||||
provider
|
||||
);
|
||||
if (
|
||||
emitSynced &&
|
||||
syncMessageType === syncProtocol.messageYjsSyncStep2 &&
|
||||
!provider.synced
|
||||
) {
|
||||
provider.synced = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MESSAGE_QUERY_AWARENESS:
|
||||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(
|
||||
provider.awareness,
|
||||
Array.from(provider.awareness.getStates().keys())
|
||||
)
|
||||
);
|
||||
break;
|
||||
case MESSAGE_AWARENESS:
|
||||
awarenessProtocol.applyAwarenessUpdate(
|
||||
provider.awareness,
|
||||
decoding.readVarUint8Array(decoder),
|
||||
provider
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.error("Unable to compute message");
|
||||
return encoder;
|
||||
}
|
||||
return encoder;
|
||||
};
|
||||
|
||||
const broadcastMessage = (provider: WebsocketProvider, buff: ArrayBuffer) => {
|
||||
if (provider.wsconnected) {
|
||||
provider.wsPublish(buff);
|
||||
}
|
||||
if (provider.bcconnected) {
|
||||
provider.mux(() => {
|
||||
bc.publish(provider.documentId, buff);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Websocket Provider for Yjs. Syncs the shared document using socket.io socket
|
||||
*/
|
||||
export class WebsocketProvider extends Observable {
|
||||
constructor(
|
||||
socket: Socket,
|
||||
documentId: string,
|
||||
userId: string,
|
||||
doc: Y.Doc,
|
||||
{
|
||||
awareness = new awarenessProtocol.Awareness(doc),
|
||||
resyncInterval = 0,
|
||||
}: {
|
||||
awareness: awarenessProtocol.Awareness,
|
||||
resyncInterval: number,
|
||||
} = {}
|
||||
) {
|
||||
super();
|
||||
this.socket = socket;
|
||||
this.bcChannel = documentId;
|
||||
this.documentId = documentId;
|
||||
this.userId = userId;
|
||||
this.doc = doc;
|
||||
this.awareness = awareness;
|
||||
this.wsconnected = false;
|
||||
this.bcconnected = false;
|
||||
this.shouldConnect = true;
|
||||
this.mux = mutex.createMutex();
|
||||
this._synced = false;
|
||||
this._resyncInterval = 0;
|
||||
|
||||
if (resyncInterval > 0) {
|
||||
this._resyncInterval = setInterval(() => {
|
||||
if (this.ws) {
|
||||
// resend sync step 1
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
||||
syncProtocol.writeSyncStep1(encoder, doc);
|
||||
this.wsPublish(encoding.toUint8Array(encoder));
|
||||
}
|
||||
}, resyncInterval);
|
||||
}
|
||||
|
||||
this.doc.on("update", this._updateHandler);
|
||||
|
||||
window.addEventListener("beforeunload", this._unloadHandler);
|
||||
awareness.on("update", this._awarenessUpdateHandler);
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
_unloadHandler = () => {
|
||||
awarenessProtocol.removeAwarenessStates(
|
||||
this.awareness,
|
||||
[this.doc.clientID],
|
||||
"window unload"
|
||||
);
|
||||
};
|
||||
|
||||
_bcSubscriber = (data: ArrayBuffer) => {
|
||||
this.mux(() => {
|
||||
const encoder = readMessage(this, new Uint8Array(data), false);
|
||||
if (encoding.length(encoder) > 1) {
|
||||
bc.publish(this.bcChannel, encoding.toUint8Array(encoder));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Listens to Yjs updates and sends them to remote peers (ws and broadcastchannel)
|
||||
*/
|
||||
_updateHandler = (update: Uint8Array, origin: any) => {
|
||||
if (origin !== this || origin === null) {
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
||||
syncProtocol.writeUpdate(encoder, update);
|
||||
broadcastMessage(this, encoding.toUint8Array(encoder));
|
||||
}
|
||||
};
|
||||
|
||||
_awarenessUpdateHandler = ({ added, updated, removed }: any, origin: any) => {
|
||||
const changedClients = added.concat(updated).concat(removed);
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients)
|
||||
);
|
||||
broadcastMessage(this, encoding.toUint8Array(encoder));
|
||||
};
|
||||
|
||||
get synced() {
|
||||
return this._synced;
|
||||
}
|
||||
|
||||
set synced(state: boolean) {
|
||||
if (this._synced !== state) {
|
||||
this._synced = state;
|
||||
this.emit("sync", [state]);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._resyncInterval !== 0) {
|
||||
clearInterval(this._resyncInterval);
|
||||
}
|
||||
this.disconnect();
|
||||
this.awareness.off("update", this._awarenessUpdateHandler);
|
||||
this.doc.off("update", this._updateHandler);
|
||||
this.awareness.destroy();
|
||||
window.removeEventListener("beforeunload", this._unloadHandler);
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
connectBc() {
|
||||
if (!this.bcconnected) {
|
||||
bc.subscribe(this.bcChannel, this._bcSubscriber);
|
||||
this.bcconnected = true;
|
||||
}
|
||||
|
||||
// send sync step1 to bc
|
||||
this.mux(() => {
|
||||
// write sync step 1
|
||||
const encoderSync = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoderSync, MESSAGE_SYNC);
|
||||
syncProtocol.writeSyncStep1(encoderSync, this.doc);
|
||||
bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync));
|
||||
|
||||
// broadcast local state
|
||||
const encoderState = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoderState, MESSAGE_SYNC);
|
||||
syncProtocol.writeSyncStep2(encoderState, this.doc);
|
||||
bc.publish(this.bcChannel, encoding.toUint8Array(encoderState));
|
||||
|
||||
// write queryAwareness
|
||||
const encoderAwarenessQuery = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoderAwarenessQuery, MESSAGE_QUERY_AWARENESS);
|
||||
bc.publish(this.bcChannel, encoding.toUint8Array(encoderAwarenessQuery));
|
||||
|
||||
// broadcast local awareness state
|
||||
const encoderAwarenessState = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoderAwarenessState, MESSAGE_AWARENESS);
|
||||
encoding.writeVarUint8Array(
|
||||
encoderAwarenessState,
|
||||
awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
|
||||
this.doc.clientID,
|
||||
])
|
||||
);
|
||||
bc.publish(this.bcChannel, encoding.toUint8Array(encoderAwarenessState));
|
||||
});
|
||||
}
|
||||
|
||||
disconnectBc() {
|
||||
// broadcast message with local awareness state set to null (indicating disconnect)
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(
|
||||
this.awareness,
|
||||
[this.doc.clientID],
|
||||
new Map()
|
||||
)
|
||||
);
|
||||
broadcastMessage(this, encoding.toUint8Array(encoder));
|
||||
if (this.bcconnected) {
|
||||
bc.unsubscribe(this.bcChannel, this._bcSubscriber);
|
||||
this.bcconnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
wsPublish(data: ArrayBuffer) {
|
||||
if (!data) return;
|
||||
|
||||
this.socket.binary(true).emit("sync", {
|
||||
documentId: this.documentId,
|
||||
userId: this.userId,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
_wsMessageHandler = (event: {
|
||||
documentId: string,
|
||||
userId: string,
|
||||
data: ArrayBuffer,
|
||||
}) => {
|
||||
if (event.documentId === this.documentId) {
|
||||
const encoder = readMessage(this, new Uint8Array(event.data), true);
|
||||
if (encoding.length(encoder) > 1) {
|
||||
this.wsPublish(encoding.toUint8Array(encoder));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_wsCloseHandler = () => {
|
||||
awarenessProtocol.removeAwarenessStates(
|
||||
this.awareness,
|
||||
Array.from(this.awareness.getStates().keys()),
|
||||
this
|
||||
);
|
||||
|
||||
this.emit("status", [
|
||||
{
|
||||
status: "disconnected",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
_wsJoinHandler = (event: { documentId: string, userId: string }) => {
|
||||
if (event.userId !== this.userId || event.documentId !== this.documentId) {
|
||||
return;
|
||||
}
|
||||
console.log("user.join");
|
||||
|
||||
this.awareness.setLocalState({});
|
||||
|
||||
this.emit("status", [
|
||||
{
|
||||
status: "connected",
|
||||
},
|
||||
]);
|
||||
|
||||
console.log("writing sync step 1");
|
||||
|
||||
// always send sync step 1 when connected
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
||||
syncProtocol.writeSyncStep1(encoder, this.doc);
|
||||
this.wsPublish(encoding.toUint8Array(encoder));
|
||||
|
||||
// broadcast local awareness state
|
||||
if (this.awareness.getLocalState() !== null) {
|
||||
console.log("broadcast awareness state");
|
||||
|
||||
const encoderAwarenessState = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoderAwarenessState, MESSAGE_AWARENESS);
|
||||
encoding.writeVarUint8Array(
|
||||
encoderAwarenessState,
|
||||
awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
|
||||
this.doc.clientID,
|
||||
])
|
||||
);
|
||||
|
||||
this.wsPublish(encoding.toUint8Array(encoderAwarenessState));
|
||||
}
|
||||
};
|
||||
|
||||
connectWs() {
|
||||
this.socket.on("document.sync", this._wsMessageHandler);
|
||||
this.socket.on("disconnect", this._wsCloseHandler);
|
||||
this.socket.on("user.join", this._wsJoinHandler);
|
||||
}
|
||||
|
||||
disconnectWs() {
|
||||
this.socket.off("document.sync", this._wsMessageHandler);
|
||||
this.socket.off("disconnect", this._wsCloseHandler);
|
||||
this.socket.off("user.join", this._wsJoinHandler);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldConnect = false;
|
||||
this.disconnectWs();
|
||||
this.disconnectBc();
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.shouldConnect = true;
|
||||
|
||||
if (!this.wsconnected) {
|
||||
this.wsconnected = true;
|
||||
this.connectWs();
|
||||
this.connectBc();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,8 @@ import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||
const SettingsRoutes = React.lazy(() =>
|
||||
import(/* webpackChunkName: "settings" */ "./settings")
|
||||
);
|
||||
const KeyedDocument = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
|
||||
)
|
||||
const Document = React.lazy(() =>
|
||||
import(/* webpackChunkName: "document" */ "scenes/Document")
|
||||
);
|
||||
const NotFound = () => <Search notFound />;
|
||||
const RedirectDocument = ({ match }: { match: Match }) => (
|
||||
@@ -66,10 +64,10 @@ export default function AuthenticatedRoutes() {
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
component={Document}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
|
||||
+4
-6
@@ -12,10 +12,8 @@ const Authenticated = React.lazy(() =>
|
||||
const AuthenticatedRoutes = React.lazy(() =>
|
||||
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
|
||||
);
|
||||
const KeyedDocument = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
|
||||
)
|
||||
const SharedDocument = React.lazy(() =>
|
||||
import(/* webpackChunkName: "shared-document" */ "scenes/Document/Shared")
|
||||
);
|
||||
const Login = React.lazy(() =>
|
||||
import(/* webpackChunkName: "login" */ "scenes/Login")
|
||||
@@ -37,11 +35,11 @@ export default function Routes() {
|
||||
<Route exact path="/" component={Login} />
|
||||
<Route exact path="/create" component={Login} />
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
||||
<Route exact path="/share/:shareId" component={SharedDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/share/:shareId/doc/${slug}`}
|
||||
component={KeyedDocument}
|
||||
component={SharedDocument}
|
||||
/>
|
||||
<Authenticated>
|
||||
<AuthenticatedRoutes />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as React from "react";
|
||||
import { Switch, Redirect } from "react-router-dom";
|
||||
import Details from "scenes/Settings/Details";
|
||||
import Features from "scenes/Settings/Features";
|
||||
import Groups from "scenes/Settings/Groups";
|
||||
import ImportExport from "scenes/Settings/ImportExport";
|
||||
import Notifications from "scenes/Settings/Notifications";
|
||||
@@ -19,6 +20,7 @@ export default function SettingsRoutes() {
|
||||
<Switch>
|
||||
<Route exact path="/settings" component={Profile} />
|
||||
<Route exact path="/settings/details" component={Details} />
|
||||
<Route exact path="/settings/features" component={Features} />
|
||||
<Route exact path="/settings/security" component={Security} />
|
||||
<Route exact path="/settings/members" component={People} />
|
||||
<Route exact path="/settings/groups" component={Groups} />
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// @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;
|
||||
+17
-19
@@ -149,27 +149,25 @@ function CollectionScene() {
|
||||
/>
|
||||
</Action>
|
||||
{can.update && (
|
||||
<>
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentUrl(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentUrl(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Separator />
|
||||
</>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
<Separator />
|
||||
<Action>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import DataLoader from "./components/DataLoader";
|
||||
|
||||
class KeyedDocument extends React.Component<*> {
|
||||
componentWillUnmount() {
|
||||
this.props.ui.clearActiveDocument();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { documentSlug, revisionId } = this.props.match.params;
|
||||
|
||||
// the urlId portion of the url does not include the slugified title
|
||||
// we only want to force a re-mount of the document component when the
|
||||
// document changes, not when the title does so only this portion is used
|
||||
// for the key.
|
||||
const urlParts = documentSlug ? documentSlug.split("-") : [];
|
||||
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
|
||||
|
||||
return <DataLoader key={[urlId, revisionId].join("/")} {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("ui")(KeyedDocument);
|
||||
@@ -0,0 +1,61 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { type Match } from "react-router-dom";
|
||||
import { useTheme } from "styled-components";
|
||||
import Error404 from "scenes/Error404";
|
||||
import ErrorOffline from "scenes/ErrorOffline";
|
||||
import useStores from "../../hooks/useStores";
|
||||
import Document from "./components/Document";
|
||||
import Loading from "./components/Loading";
|
||||
import { type LocationWithState } from "types";
|
||||
import { OfflineError } from "utils/errors";
|
||||
|
||||
type Props = {|
|
||||
match: Match,
|
||||
location: LocationWithState,
|
||||
|};
|
||||
|
||||
export default function SharedEditor(props: Props) {
|
||||
const theme = useTheme();
|
||||
const [response, setResponse] = React.useState();
|
||||
const [error, setError] = React.useState<?Error>();
|
||||
const { documents } = useStores();
|
||||
const { shareId, documentSlug } = props.match.params;
|
||||
|
||||
// ensure the wider page color always matches the theme
|
||||
React.useEffect(() => {
|
||||
window.document.body.style.background = theme.background;
|
||||
}, [theme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const response = await documents.fetch(documentSlug, {
|
||||
shareId,
|
||||
});
|
||||
setResponse(response);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [documents, documentSlug, shareId]);
|
||||
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return <Loading location={props.location} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Document
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
location={props.location}
|
||||
shareId={shareId}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,10 +18,8 @@ import Document from "models/Document";
|
||||
import Revision from "models/Revision";
|
||||
import Error404 from "scenes/Error404";
|
||||
import ErrorOffline from "scenes/ErrorOffline";
|
||||
import DocumentComponent from "./Document";
|
||||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
import SocketPresence from "./SocketPresence";
|
||||
import { type LocationWithState, type NavigationNode } from "types";
|
||||
import { NotFoundError, OfflineError } from "utils/errors";
|
||||
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
||||
@@ -29,6 +27,7 @@ import { isInternalUrl } from "utils/urls";
|
||||
type Props = {|
|
||||
match: Match,
|
||||
location: LocationWithState,
|
||||
auth: AuthStore,
|
||||
shares: SharesStore,
|
||||
documents: DocumentsStore,
|
||||
policies: PoliciesStore,
|
||||
@@ -36,6 +35,7 @@ type Props = {|
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
history: RouterHistory,
|
||||
children: (any) => React.Node,
|
||||
|};
|
||||
|
||||
const sharedTreeCache = {};
|
||||
@@ -121,7 +121,7 @@ class DataLoader extends React.Component<Props> {
|
||||
|
||||
return sortBy(
|
||||
results.map((document) => {
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
const time = formatDistanceToNow(document.updatedAt, {
|
||||
addSuffix: true,
|
||||
});
|
||||
return {
|
||||
@@ -223,7 +223,7 @@ class DataLoader extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { location, policies, ui } = this.props;
|
||||
const { location, policies, auth, ui } = this.props;
|
||||
|
||||
if (this.error) {
|
||||
return this.error instanceof OfflineError ? (
|
||||
@@ -233,10 +233,11 @@ class DataLoader extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
|
||||
const team = auth.team;
|
||||
const document = this.document;
|
||||
const revision = this.revision;
|
||||
|
||||
if (!document) {
|
||||
if (!document || !team) {
|
||||
return (
|
||||
<>
|
||||
<Loading location={location} />
|
||||
@@ -246,21 +247,25 @@ class DataLoader extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const abilities = policies.abilities(document.id);
|
||||
const key = team.multiplayerEditor
|
||||
? ""
|
||||
: this.isEditing
|
||||
? "editing"
|
||||
: "read-only";
|
||||
|
||||
return (
|
||||
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
|
||||
<React.Fragment key={key}>
|
||||
{this.isEditing && <HideSidebar ui={ui} />}
|
||||
<DocumentComponent
|
||||
document={document}
|
||||
revision={revision}
|
||||
abilities={abilities}
|
||||
location={location}
|
||||
readOnly={!this.isEditing || !abilities.update || document.isArchived}
|
||||
onSearchLink={this.onSearchLink}
|
||||
onCreateLink={this.onCreateLink}
|
||||
sharedTree={this.sharedTree}
|
||||
/>
|
||||
</SocketPresence>
|
||||
{this.props.children({
|
||||
document,
|
||||
revision,
|
||||
abilities,
|
||||
readOnly: !this.isEditing || !abilities.update || document.isArchived,
|
||||
onSearchLink: this.onSearchLink,
|
||||
onCreateLink: this.onCreateLink,
|
||||
sharedTree: this.sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import { InputIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import keydown from "react-keydown";
|
||||
import { Prompt, Route, withRouter } from "react-router-dom";
|
||||
import type { RouterHistory, Match } from "react-router-dom";
|
||||
import type { RouterHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import * as Y from "yjs";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Document from "models/Document";
|
||||
@@ -17,6 +18,7 @@ import DocumentMove from "scenes/DocumentMove";
|
||||
import Branding from "components/Branding";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import LoadingEllipsis from "components/LoadingEllipsis";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import Modal from "components/Modal";
|
||||
@@ -31,6 +33,7 @@ import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
||||
import MarkAsViewed from "./MarkAsViewed";
|
||||
import PublicReferences from "./PublicReferences";
|
||||
import References from "./References";
|
||||
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
|
||||
import { type LocationWithState, type NavigationNode, type Theme } from "types";
|
||||
import { isCustomDomain } from "utils/domains";
|
||||
import { emojiToUrl } from "utils/emoji";
|
||||
@@ -54,7 +57,6 @@ Are you sure you want to discard them?
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
match: Match,
|
||||
history: RouterHistory,
|
||||
location: LocationWithState,
|
||||
sharedTree: ?NavigationNode,
|
||||
@@ -62,6 +64,14 @@ type Props = {
|
||||
document: Document,
|
||||
revision: Revision,
|
||||
readOnly: boolean,
|
||||
isShare?: boolean,
|
||||
multiplayer: {
|
||||
isConnected: boolean,
|
||||
isReconnecting: boolean,
|
||||
isRemoteSynced: boolean,
|
||||
provider: ?WebsocketProvider,
|
||||
doc: Y.Doc,
|
||||
},
|
||||
onCreateLink: (title: string) => Promise<string>,
|
||||
onSearchLink: (term: string) => any,
|
||||
theme: Theme,
|
||||
@@ -194,7 +204,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
autosave?: boolean,
|
||||
} = {}
|
||||
) => {
|
||||
const { document } = this.props;
|
||||
const { document, auth } = this.props;
|
||||
|
||||
// prevent saves when we are already saving
|
||||
if (document.isSaving) return;
|
||||
@@ -222,10 +232,22 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.isPublishing = !!options.publish;
|
||||
|
||||
try {
|
||||
const savedDocument = await document.save({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
let savedDocument = document;
|
||||
if (auth.team && auth.team.multiplayerEditor) {
|
||||
// update does not send "text" field to the API, this is a workaround
|
||||
// while the multiplayer editor is toggleable. Once it's finalized
|
||||
// this can be cleaned up to single code path
|
||||
savedDocument = await document.update({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
} else {
|
||||
savedDocument = await document.save({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
}
|
||||
|
||||
this.isDirty = false;
|
||||
this.lastRevision = savedDocument.revision;
|
||||
|
||||
@@ -270,6 +292,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
};
|
||||
|
||||
onChange = (getEditorText) => {
|
||||
const { auth } = this.props;
|
||||
if (auth.team && auth.team.multiplayerEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.getEditorText = getEditorText;
|
||||
|
||||
// document change while read only is presumed to be a checkbox edit,
|
||||
@@ -298,9 +325,10 @@ class DocumentScene extends React.Component<Props> {
|
||||
document,
|
||||
revision,
|
||||
readOnly,
|
||||
abilities,
|
||||
abilities = {},
|
||||
auth,
|
||||
ui,
|
||||
multiplayer,
|
||||
match,
|
||||
} = this.props;
|
||||
const team = auth.team;
|
||||
@@ -326,7 +354,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
auto
|
||||
>
|
||||
<Route
|
||||
path={`${match.url}/move`}
|
||||
path={`${document.url}/move`}
|
||||
component={() => (
|
||||
<Modal
|
||||
title={`Move ${document.noun}`}
|
||||
@@ -350,7 +378,12 @@ class DocumentScene extends React.Component<Props> {
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Prompt
|
||||
when={this.isDirty && !this.isUploading}
|
||||
when={
|
||||
this.isDirty &&
|
||||
!this.isUploading &&
|
||||
!!team &&
|
||||
!team.multiplayerEditor
|
||||
}
|
||||
message={DISCARD_CHANGES}
|
||||
/>
|
||||
<Prompt
|
||||
@@ -409,12 +442,28 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
{team &&
|
||||
multiplayer &&
|
||||
!multiplayer.isConnected &&
|
||||
team.multiplayerEditor && (
|
||||
<Notice muted>
|
||||
Connection lost. Any edits will sync once you’re back
|
||||
online.{" "}
|
||||
{multiplayer.isReconnecting && (
|
||||
<>
|
||||
Trying to reconnect
|
||||
<LoadingEllipsis />
|
||||
</>
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
<React.Suspense fallback={<LoadingPlaceholder />}>
|
||||
<Flex auto={!readOnly}>
|
||||
{showContents && <Contents headings={headings} />}
|
||||
<Editor
|
||||
id={document.id}
|
||||
innerRef={this.editor}
|
||||
canShowHoverPreviews={!isShare}
|
||||
shareId={shareId}
|
||||
isDraft={document.isDraft}
|
||||
template={document.isTemplate}
|
||||
@@ -436,6 +485,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
readOnly={readOnly}
|
||||
readOnlyWriteCheckboxes={readOnly && abilities.update}
|
||||
ui={this.props.ui}
|
||||
multiplayer={this.props.multiplayer}
|
||||
>
|
||||
{shareId && (
|
||||
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import Textarea from "react-autosize-textarea";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import * as Y from "yjs";
|
||||
import { MAX_TITLE_LENGTH } from "shared/constants";
|
||||
import { light } from "shared/styles/theme";
|
||||
import parseTitle from "shared/utils/parseTitle";
|
||||
@@ -15,6 +16,8 @@ import Editor, { type Props as EditorProps } from "components/Editor";
|
||||
import Flex from "components/Flex";
|
||||
import HoverPreview from "components/HoverPreview";
|
||||
import Star, { AnimatedStar } from "components/Star";
|
||||
import MultiplayerEditor from "./MultiplayerEditor";
|
||||
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
@@ -24,16 +27,24 @@ type Props = {|
|
||||
title: string,
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
shareId: ?string,
|
||||
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
canShowHoverPreviews?: boolean,
|
||||
readOnly?: boolean,
|
||||
onSave: ({ publish?: boolean, done?: boolean, autosave?: boolean }) => mixed,
|
||||
innerRef: { current: any },
|
||||
multiplayer: {
|
||||
isConnected: boolean,
|
||||
isReconnecting: boolean,
|
||||
isRemoteSynced: boolean,
|
||||
provider: ?WebsocketProvider,
|
||||
doc: Y.Doc,
|
||||
},
|
||||
shareId: ?string,
|
||||
children: React.Node,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class DocumentEditor extends React.Component<Props> {
|
||||
@observable activeLinkEvent: ?MouseEvent;
|
||||
ref = React.createRef<HTMLDivElement | HTMLInputElement>();
|
||||
|
||||
focusAtStart = () => {
|
||||
if (this.props.innerRef.current) {
|
||||
@@ -98,15 +109,18 @@ class DocumentEditor extends React.Component<Props> {
|
||||
title,
|
||||
onChangeTitle,
|
||||
isDraft,
|
||||
shareId,
|
||||
canShowHoverPreviews,
|
||||
readOnly,
|
||||
innerRef,
|
||||
multiplayer,
|
||||
shareId,
|
||||
children,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const { emoji } = parseTitle(title);
|
||||
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
|
||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||
const normalizedTitle =
|
||||
!title && readOnly ? document.titleWithDefault : title;
|
||||
|
||||
@@ -115,10 +129,8 @@ 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} />}
|
||||
@@ -126,7 +138,6 @@ class DocumentEditor extends React.Component<Props> {
|
||||
) : (
|
||||
<Title
|
||||
type="text"
|
||||
ref={this.ref}
|
||||
onChange={onChangeTitle}
|
||||
onKeyDown={this.handleTitleKeyDown}
|
||||
placeholder={document.placeholder}
|
||||
@@ -134,7 +145,6 @@ class DocumentEditor extends React.Component<Props> {
|
||||
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
|
||||
autoFocus={!title}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
dir="auto"
|
||||
/>
|
||||
)}
|
||||
{!shareId && (
|
||||
@@ -142,26 +152,22 @@ 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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
<EditorComponent
|
||||
ref={innerRef}
|
||||
autoFocus={!!title && !this.props.defaultValue}
|
||||
placeholder="…the rest is up to you"
|
||||
onHoverLink={this.handleLinkActive}
|
||||
scrollTo={window.location.hash}
|
||||
readOnly={readOnly}
|
||||
multiplayer={multiplayer}
|
||||
shareId={shareId}
|
||||
grow
|
||||
{...rest}
|
||||
/>
|
||||
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
|
||||
{this.activeLinkEvent && !shareId && readOnly && (
|
||||
{this.activeLinkEvent && canShowHoverPreviews && readOnly && (
|
||||
<HoverPreview
|
||||
node={this.activeLinkEvent.target}
|
||||
event={this.activeLinkEvent}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import * as Y from "yjs";
|
||||
import Editor from "components/Editor";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
|
||||
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
|
||||
|
||||
type Props = {
|
||||
multiplayer: {
|
||||
isConnected: boolean,
|
||||
isReconnecting: boolean,
|
||||
isRemoteSynced: boolean,
|
||||
provider: ?WebsocketProvider,
|
||||
doc: Y.Doc,
|
||||
},
|
||||
};
|
||||
|
||||
export default function MultiplayerEditor({ multiplayer, ...props }: Props) {
|
||||
const user = useCurrentUser();
|
||||
const [showCachedDocument, setShowCachedDocument] = React.useState(true);
|
||||
const { provider, doc, isRemoteSynced } = multiplayer;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isRemoteSynced) {
|
||||
setTimeout(() => setShowCachedDocument(false), 100);
|
||||
}
|
||||
}, [showCachedDocument, isRemoteSynced]);
|
||||
|
||||
const extensions = React.useMemo(() => {
|
||||
console.log("extensions");
|
||||
|
||||
return [
|
||||
new MultiplayerExtension({
|
||||
user,
|
||||
provider,
|
||||
doc,
|
||||
}),
|
||||
];
|
||||
}, [user, provider, doc]);
|
||||
|
||||
return (
|
||||
<span style={{ position: "relative" }}>
|
||||
{isRemoteSynced && (
|
||||
<Editor
|
||||
{...props}
|
||||
key="multiplayer"
|
||||
defaultValue={undefined}
|
||||
value={undefined}
|
||||
extensions={extensions}
|
||||
style={{ position: "absolute", width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
{showCachedDocument && (
|
||||
<Editor
|
||||
{...props}
|
||||
style={{ position: "absolute", width: "100%" }}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const DocumentLink = styled(Link)`
|
||||
const Title = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
@@ -77,7 +78,7 @@ function ReferenceListItem({
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Title dir="auto">
|
||||
<Title>
|
||||
{document.emoji ? (
|
||||
<Emoji>{document.emoji}</Emoji>
|
||||
) : (
|
||||
|
||||
@@ -1,77 +1,113 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { USER_PRESENCE_INTERVAL } from "shared/constants";
|
||||
import * as Y from "yjs";
|
||||
import { SocketContext } from "components/SocketProvider";
|
||||
import useStores from "hooks/useStores";
|
||||
import { WebsocketProvider } from "multiplayer/WebsocketProvider";
|
||||
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
children: ({
|
||||
provider: ?WebsocketProvider,
|
||||
isReconnecting: boolean,
|
||||
isConnected: boolean,
|
||||
doc: Y.Doc,
|
||||
}) => React.Node,
|
||||
isMultiplayer: boolean,
|
||||
documentId: string,
|
||||
isEditing: boolean,
|
||||
userId?: string,
|
||||
};
|
||||
|
||||
export default class SocketPresence extends React.Component<Props> {
|
||||
static contextType = SocketContext;
|
||||
previousContext: any;
|
||||
editingInterval: IntervalID;
|
||||
export default function SocketPresence(props: Props) {
|
||||
const { presence } = useStores();
|
||||
const context = React.useContext(SocketContext);
|
||||
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
|
||||
const [isConnected, setConnected] = React.useState(
|
||||
context ? context.connected : false
|
||||
);
|
||||
const [isReconnecting, setReconnecting] = React.useState(false);
|
||||
const [doc] = React.useState(() =>
|
||||
props.isMultiplayer ? new Y.Doc() : undefined
|
||||
);
|
||||
const [provider] = React.useState(() =>
|
||||
props.isMultiplayer && props.userId
|
||||
? new WebsocketProvider(context, props.documentId, props.userId, doc)
|
||||
: undefined
|
||||
);
|
||||
|
||||
componentDidMount() {
|
||||
this.editingInterval = setInterval(() => {
|
||||
if (this.props.isEditing) {
|
||||
this.emitPresence();
|
||||
if (provider) {
|
||||
provider.once("sync", () => setRemoteSynced(true));
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (provider) {
|
||||
provider.destroy();
|
||||
}
|
||||
}, USER_PRESENCE_INTERVAL);
|
||||
this.setupOnce();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
this.setupOnce();
|
||||
const awareness = provider && provider.awareness;
|
||||
React.useEffect(() => {
|
||||
const onUpdate = () => {
|
||||
presence.updateFromAwareness(props.documentId, awareness);
|
||||
};
|
||||
|
||||
if (prevProps.isEditing !== this.props.isEditing) {
|
||||
this.emitPresence();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.context) {
|
||||
this.context.emit("leave", { documentId: this.props.documentId });
|
||||
this.context.off("authenticated", this.emitJoin);
|
||||
if (awareness) {
|
||||
awareness.on("update", onUpdate);
|
||||
}
|
||||
|
||||
clearInterval(this.editingInterval);
|
||||
}
|
||||
|
||||
setupOnce = () => {
|
||||
if (this.context && this.context !== this.previousContext) {
|
||||
this.previousContext = this.context;
|
||||
|
||||
if (this.context.authenticated) {
|
||||
this.emitJoin();
|
||||
return () => {
|
||||
if (awareness) {
|
||||
awareness.off("update", onUpdate);
|
||||
}
|
||||
this.context.on("authenticated", () => {
|
||||
this.emitJoin();
|
||||
});
|
||||
};
|
||||
}, [presence, props.documentId, awareness]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context) return;
|
||||
|
||||
const emitJoin = () => {
|
||||
if (!context) return;
|
||||
context.emit("join", { documentId: props.documentId });
|
||||
};
|
||||
|
||||
const updateStatus = () => {
|
||||
setConnected(context.connected);
|
||||
};
|
||||
|
||||
const reconnectingStopped = () => {
|
||||
setReconnecting(false);
|
||||
};
|
||||
|
||||
context.on("connect", updateStatus);
|
||||
context.on("disconnect", updateStatus);
|
||||
context.on("reconnect", reconnectingStopped);
|
||||
context.on("reconnect_attempt", setReconnecting);
|
||||
context.on("reconnect_failed", reconnectingStopped);
|
||||
context.on("authenticated", emitJoin);
|
||||
|
||||
if (context.authenticated) {
|
||||
emitJoin();
|
||||
}
|
||||
};
|
||||
|
||||
emitJoin = () => {
|
||||
if (!this.context) return;
|
||||
return () => {
|
||||
if (!context) return;
|
||||
|
||||
this.context.emit("join", {
|
||||
documentId: this.props.documentId,
|
||||
isEditing: this.props.isEditing,
|
||||
});
|
||||
};
|
||||
context.emit("leave", { documentId: props.documentId });
|
||||
context.off("authenticated", emitJoin);
|
||||
context.off("connect", updateStatus);
|
||||
context.off("disconnect", updateStatus);
|
||||
context.off("reconnect", reconnectingStopped);
|
||||
context.off("reconnect_attempt", setReconnecting);
|
||||
context.off("reconnect_failed", reconnectingStopped);
|
||||
};
|
||||
}, [context, props.documentId, props.userId]);
|
||||
|
||||
emitPresence = () => {
|
||||
if (!this.context) return;
|
||||
|
||||
this.context.emit("presence", {
|
||||
documentId: this.props.documentId,
|
||||
isEditing: this.props.isEditing,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return this.props.children || null;
|
||||
}
|
||||
return props.children({
|
||||
isConnected,
|
||||
isRemoteSynced,
|
||||
isReconnecting,
|
||||
provider,
|
||||
doc,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,66 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { type Match } from "react-router-dom";
|
||||
import DataLoader from "./components/DataLoader";
|
||||
export default DataLoader;
|
||||
import Document from "./components/Document";
|
||||
import SocketPresence from "./components/SocketPresence";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
import { type LocationWithState } from "types";
|
||||
|
||||
type Props = {|
|
||||
location: LocationWithState,
|
||||
match: Match,
|
||||
|};
|
||||
|
||||
export default function DocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => ui.clearActiveDocument();
|
||||
}, [ui]);
|
||||
|
||||
const { documentSlug, revisionId } = props.match.params;
|
||||
|
||||
// the urlId portion of the url does not include the slugified title
|
||||
// we only want to force a re-mount of the document component when the
|
||||
// document changes, not when the title does so only this portion is used
|
||||
// for the key.
|
||||
const urlParts = documentSlug ? documentSlug.split("-") : [];
|
||||
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
|
||||
const key = [urlId, revisionId].join("/");
|
||||
const isMultiplayer = team.multiplayerEditor;
|
||||
|
||||
return (
|
||||
<DataLoader key={key} match={props.match}>
|
||||
{({ document, ...rest }) => {
|
||||
const isActive =
|
||||
!document.isArchived && !document.isDeleted && !revisionId;
|
||||
|
||||
if (isActive) {
|
||||
return (
|
||||
<SocketPresence
|
||||
documentId={document.id}
|
||||
userId={user.id}
|
||||
isMultiplayer={isMultiplayer}
|
||||
>
|
||||
{(multiplayer) => (
|
||||
<Document
|
||||
document={document}
|
||||
match={props.match}
|
||||
multiplayer={multiplayer}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</SocketPresence>
|
||||
);
|
||||
}
|
||||
|
||||
return <Document document={document} match={props.match} {...rest} />;
|
||||
}}
|
||||
</DataLoader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// @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("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentPermanentDelete);
|
||||
@@ -50,13 +50,10 @@ 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,
|
||||
},
|
||||
{ skipEmptyString: true }
|
||||
),
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -102,9 +103,8 @@ class Search extends React.Component<Props> {
|
||||
if (ev.key === "ArrowDown") {
|
||||
ev.preventDefault();
|
||||
if (this.firstDocument) {
|
||||
if (this.firstDocument instanceof HTMLElement) {
|
||||
this.firstDocument.focus();
|
||||
}
|
||||
const element = ReactDOM.findDOMNode(this.firstDocument);
|
||||
if (element instanceof HTMLElement) element.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -140,13 +140,10 @@ 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,
|
||||
},
|
||||
{ skipEmptyString: true }
|
||||
),
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Checkbox from "components/Checkbox";
|
||||
import HelpText from "components/HelpText";
|
||||
import PageTitle from "components/PageTitle";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Features extends React.Component<Props> {
|
||||
form: ?HTMLFormElement;
|
||||
@observable multiplayerEditor: boolean;
|
||||
|
||||
componentDidMount() {
|
||||
const { auth } = this.props;
|
||||
if (auth.team) {
|
||||
this.multiplayerEditor = auth.team.multiplayerEditor;
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = async (ev: SyntheticInputEvent<*>) => {
|
||||
switch (ev.target.name) {
|
||||
case "multiplayerEditor":
|
||||
this.multiplayerEditor = ev.target.checked;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
await this.props.auth.updateTeam({
|
||||
multiplayerEditor: this.multiplayerEditor,
|
||||
});
|
||||
this.showSuccessMessage();
|
||||
};
|
||||
|
||||
showSuccessMessage = debounce(() => {
|
||||
this.props.ui.showToast("Settings saved");
|
||||
}, 500);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Labs" />
|
||||
<h1>Labs</h1>
|
||||
<HelpText>
|
||||
Enable experimental features that are still under development.
|
||||
</HelpText>
|
||||
|
||||
<Checkbox
|
||||
label="Multiplayer editor"
|
||||
name="multiplayerEditor"
|
||||
checked={this.multiplayerEditor}
|
||||
onChange={this.handleChange}
|
||||
note="Allow multiple team members to edit documents at the same time"
|
||||
/>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("auth", "ui")(Features);
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
@@ -34,31 +33,25 @@ function Groups() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("Groups")}
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
actions={
|
||||
<>
|
||||
{can.createGroup && (
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNewGroupModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{`${t("New group")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Scene title={t("Groups")} icon={<GroupIcon color="currentColor" />}>
|
||||
<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}
|
||||
|
||||
@@ -1,86 +1,89 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import APITokenNew from "scenes/APITokenNew";
|
||||
import { Action } from "components/Actions";
|
||||
import ApiKeysStore from "stores/ApiKeysStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Button from "components/Button";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
import Modal from "components/Modal";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import Input from "components/Input";
|
||||
import List from "components/List";
|
||||
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";
|
||||
|
||||
function Tokens() {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys, policies } = useStores();
|
||||
const [newModalOpen, setNewModalOpen] = React.useState(false);
|
||||
const can = policies.abilities(team.id);
|
||||
type Props = {
|
||||
apiKeys: ApiKeysStore,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
const handleNewModalOpen = React.useCallback(() => {
|
||||
setNewModalOpen(true);
|
||||
}, []);
|
||||
@observer
|
||||
class Tokens extends React.Component<Props> {
|
||||
@observable name: string = "";
|
||||
|
||||
const handleNewModalClose = React.useCallback(() => {
|
||||
setNewModalOpen(false);
|
||||
}, []);
|
||||
componentDidMount() {
|
||||
this.props.apiKeys.fetchPage({ limit: 100 });
|
||||
}
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API Tokens")}
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New token")}…`}
|
||||
onClick={handleNewModalOpen}
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.orderedData}
|
||||
heading={<Subheading sticky>{t("Tokens")}</Subheading>}
|
||||
renderItem={(token) => (
|
||||
<TokenListItem key={token.id} token={token} onDelete={token.delete} />
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t("Create a token")}
|
||||
onRequestClose={handleNewModalClose}
|
||||
isOpen={newModalOpen}
|
||||
>
|
||||
<APITokenNew onSubmit={handleNewModalClose} />
|
||||
</Modal>
|
||||
</Scene>
|
||||
);
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(Tokens);
|
||||
export default inject("apiKeys", "ui")(Tokens);
|
||||
|
||||
@@ -4,20 +4,17 @@ 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} – <code>{token.secret}</code>
|
||||
</>
|
||||
}
|
||||
title={token.name}
|
||||
subtitle={<code>{token.secret}</code>}
|
||||
actions={
|
||||
<Button onClick={() => onDelete(token.id)} neutral>
|
||||
Revoke
|
||||
|
||||
@@ -52,7 +52,7 @@ function UserProfile(props: Props) {
|
||||
? t("Joined")
|
||||
: t("Invited")}{" "}
|
||||
{t("{{ time }} ago.", {
|
||||
time: formatDistanceToNow(Date.parse(user.createdAt)),
|
||||
time: formatDistanceToNow(new Date(user.createdAt)),
|
||||
})}
|
||||
{user.isAdmin && (
|
||||
<StyledBadge primary={user.isAdmin}>{t("Admin")}</StyledBadge>
|
||||
|
||||
@@ -34,6 +34,32 @@ export default class PresenceStore {
|
||||
this.data.set(documentId, existing);
|
||||
}
|
||||
|
||||
@action updateFromAwareness(documentId: string, awareness: any) {
|
||||
const existing = this.data.get(documentId) || new Map();
|
||||
const clients = Array.from(awareness.states.values());
|
||||
const userIds = clients.map((client) => client.user && client.user.id);
|
||||
|
||||
existing.forEach((value, key) => {
|
||||
if (!userIds.includes(key)) {
|
||||
existing.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
clients.forEach((client) => {
|
||||
if (!client.user) {
|
||||
return;
|
||||
}
|
||||
const userId = client.user.id;
|
||||
|
||||
existing.set(userId, {
|
||||
isEditing: !!client.cursor,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
this.data.set(documentId, existing);
|
||||
}
|
||||
|
||||
// called when a user presence message is received – user.presence websocket
|
||||
// message.
|
||||
// While in edit mode a message is sent every USER_PRESENCE_INTERVAL, if
|
||||
|
||||
@@ -603,7 +603,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
async update(params: {
|
||||
id: string,
|
||||
title: string,
|
||||
text: string,
|
||||
text?: string,
|
||||
lastRevision: number,
|
||||
}) {
|
||||
const document = await super.update(params);
|
||||
@@ -616,8 +616,8 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
}
|
||||
|
||||
@action
|
||||
async delete(document: Document, options?: {| permanent: boolean |}) {
|
||||
await super.delete(document, options);
|
||||
async delete(document: Document) {
|
||||
await super.delete(document);
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// @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;
|
||||
@@ -1,2 +0,0 @@
|
||||
// @flow
|
||||
export const runAllPromises = () => new Promise<void>(setImmediate);
|
||||
Vendored
+377
@@ -0,0 +1,377 @@
|
||||
// flow-typed signature: 97da878aea98698d6c06f8a696bb62af
|
||||
// flow-typed version: <<STUB>>/lib0_v0.2.34/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'lib0'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
// @flow
|
||||
declare module "lib0" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module "lib0/array" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/bin/gendocs" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/binary" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/broadcastchannel" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/buffer" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/component" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/conditions" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/decoding" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/diff" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/dist/test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/dom" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/encoding" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/environment" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/error" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/eventloop" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/function" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/indexeddb" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/isomorphic" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/iterator" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/json" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/logging" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/map" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/math" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/metric" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/mutex" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/number" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/object" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/observable" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/pair" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng/Mt19937" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng/Xoroshiro128plus" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/prng/Xorshift32" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/promise" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/queue" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/random" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/set" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/sort" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/statistics" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/storage" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/string" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/symbol" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/testing" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/time" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/tree" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/url" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "lib0/websocket" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module "lib0/array.js" {
|
||||
declare module.exports: $Exports<"lib0/array">;
|
||||
}
|
||||
declare module "lib0/bin/gendocs.js" {
|
||||
declare module.exports: $Exports<"lib0/bin/gendocs">;
|
||||
}
|
||||
declare module "lib0/binary.js" {
|
||||
declare module.exports: $Exports<"lib0/binary">;
|
||||
}
|
||||
declare module "lib0/broadcastchannel.js" {
|
||||
declare module.exports: $Exports<"lib0/broadcastchannel">;
|
||||
}
|
||||
declare module "lib0/buffer.js" {
|
||||
declare module.exports: $Exports<"lib0/buffer">;
|
||||
}
|
||||
declare module "lib0/component.js" {
|
||||
declare module.exports: $Exports<"lib0/component">;
|
||||
}
|
||||
declare module "lib0/conditions.js" {
|
||||
declare module.exports: $Exports<"lib0/conditions">;
|
||||
}
|
||||
declare module "lib0/decoding.js" {
|
||||
declare module.exports: $Exports<"lib0/decoding">;
|
||||
}
|
||||
declare module "lib0/dist/decoding.cjs" {
|
||||
declare module.exports: $Exports<"lib0/decoding">;
|
||||
}
|
||||
declare module "lib0/diff.js" {
|
||||
declare module.exports: $Exports<"lib0/diff">;
|
||||
}
|
||||
declare module "lib0/dist/test.js" {
|
||||
declare module.exports: $Exports<"lib0/dist/test">;
|
||||
}
|
||||
declare module "lib0/dom.js" {
|
||||
declare module.exports: $Exports<"lib0/dom">;
|
||||
}
|
||||
declare module "lib0/encoding.js" {
|
||||
declare module.exports: $Exports<"lib0/encoding">;
|
||||
}
|
||||
declare module "lib0/dist/encoding.cjs" {
|
||||
declare module.exports: $Exports<"lib0/encoding">;
|
||||
}
|
||||
declare module "lib0/environment.js" {
|
||||
declare module.exports: $Exports<"lib0/environment">;
|
||||
}
|
||||
declare module "lib0/error.js" {
|
||||
declare module.exports: $Exports<"lib0/error">;
|
||||
}
|
||||
declare module "lib0/eventloop.js" {
|
||||
declare module.exports: $Exports<"lib0/eventloop">;
|
||||
}
|
||||
declare module "lib0/function.js" {
|
||||
declare module.exports: $Exports<"lib0/function">;
|
||||
}
|
||||
declare module "lib0/index" {
|
||||
declare module.exports: $Exports<"lib0">;
|
||||
}
|
||||
declare module "lib0/index.js" {
|
||||
declare module.exports: $Exports<"lib0">;
|
||||
}
|
||||
declare module "lib0/indexeddb.js" {
|
||||
declare module.exports: $Exports<"lib0/indexeddb">;
|
||||
}
|
||||
declare module "lib0/isomorphic.js" {
|
||||
declare module.exports: $Exports<"lib0/isomorphic">;
|
||||
}
|
||||
declare module "lib0/iterator.js" {
|
||||
declare module.exports: $Exports<"lib0/iterator">;
|
||||
}
|
||||
declare module "lib0/json.js" {
|
||||
declare module.exports: $Exports<"lib0/json">;
|
||||
}
|
||||
declare module "lib0/logging.js" {
|
||||
declare module.exports: $Exports<"lib0/logging">;
|
||||
}
|
||||
declare module "lib0/map.js" {
|
||||
declare module.exports: $Exports<"lib0/map">;
|
||||
}
|
||||
declare module "lib0/math.js" {
|
||||
declare module.exports: $Exports<"lib0/math">;
|
||||
}
|
||||
declare module "lib0/metric.js" {
|
||||
declare module.exports: $Exports<"lib0/metric">;
|
||||
}
|
||||
declare module "lib0/mutex.js" {
|
||||
declare module.exports: $Exports<"lib0/mutex">;
|
||||
}
|
||||
declare module "lib0/dist/mutex.cjs" {
|
||||
declare module.exports: $Exports<"lib0/mutex">;
|
||||
}
|
||||
declare module "lib0/number.js" {
|
||||
declare module.exports: $Exports<"lib0/number">;
|
||||
}
|
||||
declare module "lib0/object.js" {
|
||||
declare module.exports: $Exports<"lib0/object">;
|
||||
}
|
||||
declare module "lib0/observable.js" {
|
||||
declare module.exports: $Exports<"lib0/observable">;
|
||||
}
|
||||
declare module "lib0/pair.js" {
|
||||
declare module.exports: $Exports<"lib0/pair">;
|
||||
}
|
||||
declare module "lib0/prng.js" {
|
||||
declare module.exports: $Exports<"lib0/prng">;
|
||||
}
|
||||
declare module "lib0/prng/Mt19937.js" {
|
||||
declare module.exports: $Exports<"lib0/prng/Mt19937">;
|
||||
}
|
||||
declare module "lib0/prng/Xoroshiro128plus.js" {
|
||||
declare module.exports: $Exports<"lib0/prng/Xoroshiro128plus">;
|
||||
}
|
||||
declare module "lib0/prng/Xorshift32.js" {
|
||||
declare module.exports: $Exports<"lib0/prng/Xorshift32">;
|
||||
}
|
||||
declare module "lib0/promise.js" {
|
||||
declare module.exports: $Exports<"lib0/promise">;
|
||||
}
|
||||
declare module "lib0/queue.js" {
|
||||
declare module.exports: $Exports<"lib0/queue">;
|
||||
}
|
||||
declare module "lib0/random.js" {
|
||||
declare module.exports: $Exports<"lib0/random">;
|
||||
}
|
||||
declare module "lib0/set.js" {
|
||||
declare module.exports: $Exports<"lib0/set">;
|
||||
}
|
||||
declare module "lib0/sort.js" {
|
||||
declare module.exports: $Exports<"lib0/sort">;
|
||||
}
|
||||
declare module "lib0/statistics.js" {
|
||||
declare module.exports: $Exports<"lib0/statistics">;
|
||||
}
|
||||
declare module "lib0/storage.js" {
|
||||
declare module.exports: $Exports<"lib0/storage">;
|
||||
}
|
||||
declare module "lib0/string.js" {
|
||||
declare module.exports: $Exports<"lib0/string">;
|
||||
}
|
||||
declare module "lib0/symbol.js" {
|
||||
declare module.exports: $Exports<"lib0/symbol">;
|
||||
}
|
||||
declare module "lib0/test.js" {
|
||||
declare module.exports: $Exports<"lib0/test">;
|
||||
}
|
||||
declare module "lib0/testing.js" {
|
||||
declare module.exports: $Exports<"lib0/testing">;
|
||||
}
|
||||
declare module "lib0/time.js" {
|
||||
declare module.exports: $Exports<"lib0/time">;
|
||||
}
|
||||
declare module "lib0/tree.js" {
|
||||
declare module.exports: $Exports<"lib0/tree">;
|
||||
}
|
||||
declare module "lib0/url.js" {
|
||||
declare module.exports: $Exports<"lib0/url">;
|
||||
}
|
||||
declare module "lib0/websocket.js" {
|
||||
declare module.exports: $Exports<"lib0/websocket">;
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
// flow-typed signature: 71e55e30d387153cf804d226f95c0ad8
|
||||
// flow-typed version: <<STUB>>/y-indexeddb_v^9.0.5/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'y-indexeddb'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
declare module 'y-indexeddb' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module 'y-indexeddb/dist/test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'y-indexeddb/src/y-indexeddb' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module 'y-indexeddb/dist/test.js' {
|
||||
declare module.exports: $Exports<'y-indexeddb/dist/test'>;
|
||||
}
|
||||
declare module 'y-indexeddb/src/y-indexeddb.js' {
|
||||
declare module.exports: $Exports<'y-indexeddb/src/y-indexeddb'>;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
// flow-typed signature: 2db53ec5dbb577a4e27bc465cd4670f3
|
||||
// flow-typed version: <<STUB>>/y-prosemirror_v^0.3.7/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'y-prosemirror'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
// @flow
|
||||
declare module "y-prosemirror" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module "y-prosemirror/dist/test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-prosemirror/src/lib" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-prosemirror/src/plugins/cursor-plugin" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-prosemirror/src/plugins/sync-plugin" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-prosemirror/src/plugins/undo-plugin" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-prosemirror/src/y-prosemirror" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module "y-prosemirror/dist/test.js" {
|
||||
declare module.exports: $Exports<"y-prosemirror/dist/test">;
|
||||
}
|
||||
declare module "y-prosemirror/src/lib.js" {
|
||||
declare module.exports: $Exports<"y-prosemirror/src/lib">;
|
||||
}
|
||||
declare module "y-prosemirror/src/plugins/cursor-plugin.js" {
|
||||
declare module.exports: $Exports<"y-prosemirror/src/plugins/cursor-plugin">;
|
||||
}
|
||||
declare module "y-prosemirror/src/plugins/sync-plugin.js" {
|
||||
declare module.exports: $Exports<"y-prosemirror/src/plugins/sync-plugin">;
|
||||
}
|
||||
declare module "y-prosemirror/src/plugins/undo-plugin.js" {
|
||||
declare module.exports: $Exports<"y-prosemirror/src/plugins/undo-plugin">;
|
||||
}
|
||||
declare module "y-prosemirror/src/y-prosemirror.js" {
|
||||
declare module.exports: $Exports<"y-prosemirror/src/y-prosemirror">;
|
||||
}
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
// flow-typed signature: 3ef5e4dd42591ff15af5f507abd6aa97
|
||||
// flow-typed version: <<STUB>>/y-protocols_v^1.0.1/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'y-protocols'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
// @flow
|
||||
declare module "y-protocols" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module "y-protocols/auth" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-protocols/awareness" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-protocols/awareness.test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-protocols/dist/test" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module "y-protocols/sync" {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module "y-protocols/auth.js" {
|
||||
declare module.exports: $Exports<"y-protocols/auth">;
|
||||
}
|
||||
declare module "y-protocols/awareness.js" {
|
||||
declare module.exports: $Exports<"y-protocols/awareness">;
|
||||
}
|
||||
declare module "y-protocols/dist/awareness.cjs" {
|
||||
declare module.exports: $Exports<"y-protocols/awareness">;
|
||||
}
|
||||
declare module "y-protocols/awareness.test.js" {
|
||||
declare module.exports: $Exports<"y-protocols/awareness.test">;
|
||||
}
|
||||
declare module "y-protocols/dist/test.js" {
|
||||
declare module.exports: $Exports<"y-protocols/dist/test">;
|
||||
}
|
||||
declare module "y-protocols/sync.js" {
|
||||
declare module.exports: $Exports<"y-protocols/sync">;
|
||||
}
|
||||
declare module "y-protocols/dist/sync.cjs" {
|
||||
declare module.exports: $Exports<"y-protocols/sync">;
|
||||
}
|
||||
Vendored
+430
@@ -0,0 +1,430 @@
|
||||
// flow-typed signature: ec89eac307897bef104c76ce1dd14a4d
|
||||
// flow-typed version: <<STUB>>/yjs_v^13.4.1/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'yjs'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
declare module 'yjs' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module 'yjs/dist/tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/docs/scripts/jquery.min' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/docs/scripts/linenumber' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/docs/scripts/prettify/lang-css' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/docs/scripts/prettify/prettify' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/docs/scripts/tui-doc' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/internals' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/AbstractStruct' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/ContentAny' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/ContentBinary' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/ContentDeleted' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/ContentDoc' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/ContentEmbed' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/ContentFormat' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/ContentJSON' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/ContentString' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/ContentType' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/GC' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/structs/Item' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/types/AbstractType' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/types/YArray' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/types/YMap' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/types/YText' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/types/YXmlElement' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/types/YXmlEvent' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/types/YXmlFragment' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/types/YXmlHook' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/types/YXmlText' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/AbstractConnector' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/DeleteSet' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/Doc' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/encoding' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/EventHandler' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/ID' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/isParentOf' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/logging' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/PermanentUserData' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/RelativePosition' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/Snapshot' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/StructStore' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/Transaction' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/UndoManager' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/UpdateDecoder' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/UpdateEncoder' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/src/utils/YEvent' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests/compatibility.tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests/doc.tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests/encoding.tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests/snapshot.tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests/testHelper' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests/undo-redo.tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests/y-array.tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests/y-map.tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests/y-text.tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'yjs/tests/y-xml.tests' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module 'yjs/dist/tests.js' {
|
||||
declare module.exports: $Exports<'yjs/dist/tests'>;
|
||||
}
|
||||
declare module 'yjs/docs/scripts/jquery.min.js' {
|
||||
declare module.exports: $Exports<'yjs/docs/scripts/jquery.min'>;
|
||||
}
|
||||
declare module 'yjs/docs/scripts/linenumber.js' {
|
||||
declare module.exports: $Exports<'yjs/docs/scripts/linenumber'>;
|
||||
}
|
||||
declare module 'yjs/docs/scripts/prettify/lang-css.js' {
|
||||
declare module.exports: $Exports<'yjs/docs/scripts/prettify/lang-css'>;
|
||||
}
|
||||
declare module 'yjs/docs/scripts/prettify/prettify.js' {
|
||||
declare module.exports: $Exports<'yjs/docs/scripts/prettify/prettify'>;
|
||||
}
|
||||
declare module 'yjs/docs/scripts/tui-doc.js' {
|
||||
declare module.exports: $Exports<'yjs/docs/scripts/tui-doc'>;
|
||||
}
|
||||
declare module 'yjs/src/index' {
|
||||
declare module.exports: $Exports<'yjs/src'>;
|
||||
}
|
||||
declare module 'yjs/src/index.js' {
|
||||
declare module.exports: $Exports<'yjs/src'>;
|
||||
}
|
||||
declare module 'yjs/src/internals.js' {
|
||||
declare module.exports: $Exports<'yjs/src/internals'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/AbstractStruct.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/AbstractStruct'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/ContentAny.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/ContentAny'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/ContentBinary.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/ContentBinary'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/ContentDeleted.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/ContentDeleted'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/ContentDoc.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/ContentDoc'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/ContentEmbed.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/ContentEmbed'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/ContentFormat.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/ContentFormat'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/ContentJSON.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/ContentJSON'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/ContentString.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/ContentString'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/ContentType.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/ContentType'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/GC.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/GC'>;
|
||||
}
|
||||
declare module 'yjs/src/structs/Item.js' {
|
||||
declare module.exports: $Exports<'yjs/src/structs/Item'>;
|
||||
}
|
||||
declare module 'yjs/src/types/AbstractType.js' {
|
||||
declare module.exports: $Exports<'yjs/src/types/AbstractType'>;
|
||||
}
|
||||
declare module 'yjs/src/types/YArray.js' {
|
||||
declare module.exports: $Exports<'yjs/src/types/YArray'>;
|
||||
}
|
||||
declare module 'yjs/src/types/YMap.js' {
|
||||
declare module.exports: $Exports<'yjs/src/types/YMap'>;
|
||||
}
|
||||
declare module 'yjs/src/types/YText.js' {
|
||||
declare module.exports: $Exports<'yjs/src/types/YText'>;
|
||||
}
|
||||
declare module 'yjs/src/types/YXmlElement.js' {
|
||||
declare module.exports: $Exports<'yjs/src/types/YXmlElement'>;
|
||||
}
|
||||
declare module 'yjs/src/types/YXmlEvent.js' {
|
||||
declare module.exports: $Exports<'yjs/src/types/YXmlEvent'>;
|
||||
}
|
||||
declare module 'yjs/src/types/YXmlFragment.js' {
|
||||
declare module.exports: $Exports<'yjs/src/types/YXmlFragment'>;
|
||||
}
|
||||
declare module 'yjs/src/types/YXmlHook.js' {
|
||||
declare module.exports: $Exports<'yjs/src/types/YXmlHook'>;
|
||||
}
|
||||
declare module 'yjs/src/types/YXmlText.js' {
|
||||
declare module.exports: $Exports<'yjs/src/types/YXmlText'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/AbstractConnector.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/AbstractConnector'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/DeleteSet.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/DeleteSet'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/Doc.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/Doc'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/encoding.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/encoding'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/EventHandler.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/EventHandler'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/ID.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/ID'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/isParentOf.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/isParentOf'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/logging.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/logging'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/PermanentUserData.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/PermanentUserData'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/RelativePosition.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/RelativePosition'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/Snapshot.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/Snapshot'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/StructStore.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/StructStore'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/Transaction.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/Transaction'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/UndoManager.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/UndoManager'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/UpdateDecoder.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/UpdateDecoder'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/UpdateEncoder.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/UpdateEncoder'>;
|
||||
}
|
||||
declare module 'yjs/src/utils/YEvent.js' {
|
||||
declare module.exports: $Exports<'yjs/src/utils/YEvent'>;
|
||||
}
|
||||
declare module 'yjs/tests/compatibility.tests.js' {
|
||||
declare module.exports: $Exports<'yjs/tests/compatibility.tests'>;
|
||||
}
|
||||
declare module 'yjs/tests/doc.tests.js' {
|
||||
declare module.exports: $Exports<'yjs/tests/doc.tests'>;
|
||||
}
|
||||
declare module 'yjs/tests/encoding.tests.js' {
|
||||
declare module.exports: $Exports<'yjs/tests/encoding.tests'>;
|
||||
}
|
||||
declare module 'yjs/tests/index' {
|
||||
declare module.exports: $Exports<'yjs/tests'>;
|
||||
}
|
||||
declare module 'yjs/tests/index.js' {
|
||||
declare module.exports: $Exports<'yjs/tests'>;
|
||||
}
|
||||
declare module 'yjs/tests/snapshot.tests.js' {
|
||||
declare module.exports: $Exports<'yjs/tests/snapshot.tests'>;
|
||||
}
|
||||
declare module 'yjs/tests/testHelper.js' {
|
||||
declare module.exports: $Exports<'yjs/tests/testHelper'>;
|
||||
}
|
||||
declare module 'yjs/tests/undo-redo.tests.js' {
|
||||
declare module.exports: $Exports<'yjs/tests/undo-redo.tests'>;
|
||||
}
|
||||
declare module 'yjs/tests/y-array.tests.js' {
|
||||
declare module.exports: $Exports<'yjs/tests/y-array.tests'>;
|
||||
}
|
||||
declare module 'yjs/tests/y-map.tests.js' {
|
||||
declare module.exports: $Exports<'yjs/tests/y-map.tests'>;
|
||||
}
|
||||
declare module 'yjs/tests/y-text.tests.js' {
|
||||
declare module.exports: $Exports<'yjs/tests/y-text.tests'>;
|
||||
}
|
||||
declare module 'yjs/tests/y-xml.tests.js' {
|
||||
declare module.exports: $Exports<'yjs/tests/y-xml.tests'>;
|
||||
}
|
||||
+42
-17
@@ -13,7 +13,6 @@
|
||||
"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",
|
||||
@@ -21,7 +20,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 --config=app/.jestconfig.json --runInBand --forceExit",
|
||||
"test:app": "jest",
|
||||
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
|
||||
"test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll"
|
||||
},
|
||||
@@ -29,6 +28,33 @@
|
||||
"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"
|
||||
},
|
||||
@@ -52,6 +78,7 @@
|
||||
"@sentry/tracing": "^6.3.1",
|
||||
"@tippy.js/react": "^2.2.2",
|
||||
"@tommoor/remove-markdown": "^0.3.2",
|
||||
"y-prosemirror": "^1.0.9",
|
||||
"autotrack": "^2.4.1",
|
||||
"aws-sdk": "^2.831.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
@@ -65,7 +92,6 @@
|
||||
"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",
|
||||
@@ -105,12 +131,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",
|
||||
@@ -120,12 +146,12 @@
|
||||
"pg": "^8.5.1",
|
||||
"pg-hstore": "^2.3.3",
|
||||
"polished": "3.6.5",
|
||||
"query-string": "^7.0.1",
|
||||
"query-string": "^4.3.4",
|
||||
"quoted-printable": "^1.0.1",
|
||||
"randomstring": "1.1.5",
|
||||
"raw-loader": "^0.5.1",
|
||||
"react": "^17.0.2",
|
||||
"react-autosize-textarea": "^7.1.0",
|
||||
"react-autosize-textarea": "^6.0.0",
|
||||
"react-avatar-editor": "^11.1.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-dnd": "^14.0.1",
|
||||
@@ -142,9 +168,9 @@
|
||||
"react-virtualized-auto-sizer": "^1.0.5",
|
||||
"react-waypoint": "^10.1.0",
|
||||
"react-window": "^1.8.6",
|
||||
"reakit": "^1.3.8",
|
||||
"reakit": "^1.3.6",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rich-markdown-editor": "^11.13.0",
|
||||
"rich-markdown-editor": "^11.10.0",
|
||||
"semver": "^7.3.2",
|
||||
"sequelize": "^6.3.4",
|
||||
"sequelize-cli": "^6.2.0",
|
||||
@@ -153,7 +179,7 @@
|
||||
"slate-md-serializer": "5.5.4",
|
||||
"slug": "^4.0.4",
|
||||
"smooth-scroll-into-view-if-needed": "^1.1.29",
|
||||
"socket.io": "^2.4.0",
|
||||
"socket.io": "^2.3.0",
|
||||
"socket.io-redis": "^5.4.0",
|
||||
"socketio-auth": "^0.1.1",
|
||||
"string-replace-to-array": "^1.0.3",
|
||||
@@ -165,7 +191,10 @@
|
||||
"turndown": "^6.0.0",
|
||||
"utf8": "^2.1.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "5.2.0"
|
||||
"validator": "5.2.0",
|
||||
"y-indexeddb": "^9.0.5",
|
||||
"y-protocols": "^1.0.1",
|
||||
"yjs": "^13.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.10.5",
|
||||
@@ -175,8 +204,6 @@
|
||||
"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",
|
||||
@@ -202,13 +229,11 @@
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-manifest-plugin": "^3.0.0",
|
||||
"webpack-pwa-manifest": "^4.3.0",
|
||||
"workbox-webpack-plugin": "^6.1.0",
|
||||
"yarn-deduplicate": "^3.1.0"
|
||||
"workbox-webpack-plugin": "^6.1.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"prosemirror-view": "1.18.1",
|
||||
"dot-prop": "^5.2.0",
|
||||
"js-yaml": "^3.13.1"
|
||||
},
|
||||
"version": "0.57.0"
|
||||
}
|
||||
"version": "0.56.0"
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"./server/test/setup.js"
|
||||
"./server/test/helper.js"
|
||||
],
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
exports[`#users.activate should activate a suspended user 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
|
||||
"color": "#e600e0",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
@@ -55,7 +56,8 @@ Object {
|
||||
exports[`#users.demote should demote an admin 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
|
||||
"color": "#e600e0",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
@@ -175,7 +177,8 @@ Object {
|
||||
exports[`#users.promote should promote a new admin 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
|
||||
"color": "#e600e0",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
@@ -236,7 +239,8 @@ Object {
|
||||
exports[`#users.suspend should suspend an user 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
|
||||
"color": "#e600e0",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
|
||||
+60
-96
@@ -5,7 +5,6 @@ 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,
|
||||
@@ -1000,7 +999,6 @@ router.post("documents.update", auth(), async (ctx) => {
|
||||
const editorVersion = ctx.headers["x-editor-version"];
|
||||
|
||||
ctx.assertPresent(id, "id is required");
|
||||
ctx.assertPresent(title || text, "title or text is required");
|
||||
if (append) ctx.assertPresent(text, "Text is required while appending");
|
||||
|
||||
const user = ctx.state.user;
|
||||
@@ -1012,6 +1010,7 @@ router.post("documents.update", auth(), async (ctx) => {
|
||||
}
|
||||
|
||||
const previousTitle = document.title;
|
||||
const willPublish = publish && !document.published;
|
||||
|
||||
// Update document
|
||||
if (title) document.title = title;
|
||||
@@ -1026,67 +1025,61 @@ router.post("documents.update", auth(), async (ctx) => {
|
||||
document.lastModifiedById = user.id;
|
||||
const { collection } = document;
|
||||
|
||||
let transaction;
|
||||
try {
|
||||
transaction = await sequelize.transaction();
|
||||
if (document.changed() || willPublish) {
|
||||
let transaction;
|
||||
try {
|
||||
transaction = await sequelize.transaction();
|
||||
|
||||
if (publish) {
|
||||
await document.publish({ transaction });
|
||||
} else {
|
||||
await document.save({ autosave, transaction });
|
||||
}
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (publish) {
|
||||
await document.publish(user.id, { transaction });
|
||||
} else {
|
||||
await document.save({ autosave, transaction });
|
||||
await Event.create({
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
autosave,
|
||||
done,
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
await transaction.rollback();
|
||||
|
||||
if (document.title !== previousTitle) {
|
||||
Event.add({
|
||||
name: "documents.title_change",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
previousTitle,
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (publish) {
|
||||
await Event.create({
|
||||
name: "documents.publish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: { title: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
} else {
|
||||
await Event.create({
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
autosave,
|
||||
done,
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
document.updatedBy = user;
|
||||
document.collection = collection;
|
||||
}
|
||||
|
||||
if (document.title !== previousTitle) {
|
||||
Event.add({
|
||||
name: "documents.title_change",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
previousTitle,
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
|
||||
document.updatedBy = user;
|
||||
document.collection = collection;
|
||||
|
||||
ctx.body = {
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
@@ -1175,53 +1168,24 @@ router.post("documents.archive", auth(), async (ctx) => {
|
||||
});
|
||||
|
||||
router.post("documents.delete", auth(), async (ctx) => {
|
||||
const { id, permanent } = ctx.body;
|
||||
const { id } = ctx.body;
|
||||
ctx.assertPresent(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
if (permanent) {
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
paranoid: false,
|
||||
});
|
||||
authorize(user, "permanentDelete", document);
|
||||
const document = await Document.findByPk(id, { userId: user.id });
|
||||
authorize(user, "delete", document);
|
||||
|
||||
await Document.update(
|
||||
{ parentDocumentId: null },
|
||||
{
|
||||
where: {
|
||||
parentDocumentId: document.id,
|
||||
},
|
||||
paranoid: false,
|
||||
}
|
||||
);
|
||||
await document.delete(user.id);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -984,21 +984,6 @@ 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({
|
||||
@@ -2201,26 +2186,6 @@ 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();
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
|
||||
let {
|
||||
sort = "createdAt",
|
||||
actorId,
|
||||
documentId,
|
||||
collectionId,
|
||||
direction,
|
||||
name,
|
||||
@@ -32,12 +31,10 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
|
||||
|
||||
if (actorId) {
|
||||
ctx.assertUuid(actorId, "actorId must be a UUID");
|
||||
where = { ...where, actorId };
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
ctx.assertUuid(documentId, "documentId must be a UUID");
|
||||
where = { ...where, documentId };
|
||||
where = {
|
||||
...where,
|
||||
actorId,
|
||||
};
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import app from "../app";
|
||||
import { buildEvent, buildUser } from "../test/factories";
|
||||
import { buildEvent } from "../test/factories";
|
||||
import { flushdb, seed } from "../test/support";
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
@@ -101,54 +101,6 @@ 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();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ router.post("team.update", auth(), async (ctx) => {
|
||||
sharing,
|
||||
guestSignin,
|
||||
documentEmbeds,
|
||||
multiplayerEditor,
|
||||
} = ctx.body;
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
@@ -30,6 +31,10 @@ router.post("team.update", auth(), async (ctx) => {
|
||||
if (sharing !== undefined) team.sharing = sharing;
|
||||
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
|
||||
if (guestSignin !== undefined) team.guestSignin = guestSignin;
|
||||
if (multiplayerEditor !== undefined) {
|
||||
team.multiplayerEditor = multiplayerEditor;
|
||||
}
|
||||
|
||||
if (avatarUrl !== undefined) team.avatarUrl = avatarUrl;
|
||||
|
||||
const changes = team.changed();
|
||||
|
||||
+51
-6
@@ -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 } from "../models";
|
||||
import { Op } from "../sequelize";
|
||||
import { Document, Attachment } from "../models";
|
||||
import { Op, sequelize } from "../sequelize";
|
||||
import parseAttachmentIds from "../utils/parseAttachmentIds";
|
||||
|
||||
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", "deletedAt"],
|
||||
attributes: ["id", "teamId", "text"],
|
||||
where: {
|
||||
deletedAt: {
|
||||
[Op.lt]: subDays(new Date(), 30),
|
||||
@@ -30,9 +30,54 @@ router.post("utils.gc", async (ctx) => {
|
||||
limit,
|
||||
});
|
||||
|
||||
const countDeletedDocument = await documentPermanentDeleter(documents);
|
||||
const query = `
|
||||
SELECT COUNT(id)
|
||||
FROM documents
|
||||
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
||||
"teamId" = :teamId AND
|
||||
"id" != :documentId
|
||||
`;
|
||||
|
||||
log(`Destroyed ${countDeletedDocument} 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`);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { subDays } from "date-fns";
|
||||
import TestServer from "fetch-test-server";
|
||||
import app from "../app";
|
||||
import { Document } from "../models";
|
||||
import { buildDocument } from "../test/factories";
|
||||
import { Attachment, Document } from "../models";
|
||||
import { buildAttachment, buildDocument } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
@@ -67,6 +67,94 @@ 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 = ``;
|
||||
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 = ``;
|
||||
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 = ``;
|
||||
await document1.save();
|
||||
|
||||
document.text = ``;
|
||||
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,
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
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();
|
||||
@@ -23,56 +20,19 @@ export const config = {
|
||||
router.use(methodOverride());
|
||||
router.use(validation());
|
||||
|
||||
router.post("email", errorHandling(), async (ctx) => {
|
||||
router.post("email", async (ctx) => {
|
||||
const { email } = ctx.body;
|
||||
|
||||
ctx.assertEmail(email, "email is required");
|
||||
|
||||
const users = await User.scope("withAuthentications").findAll({
|
||||
const user = await User.scope("withAuthentications").findOne({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
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 (user) {
|
||||
const team = await Team.scope("withAuthenticationProviders").findByPk(
|
||||
user.teamId
|
||||
);
|
||||
if (!team) {
|
||||
ctx.redirect(`/?notice=auth-error`);
|
||||
return;
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
// @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,
|
||||
});
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
// @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 = ``;
|
||||
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 = ``;
|
||||
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 = ``;
|
||||
await document1.save();
|
||||
|
||||
document.text = ``;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// @flow
|
||||
import { uniq } from "lodash";
|
||||
import { schema, serializer } from "rich-markdown-editor";
|
||||
import { yDocToProsemirror } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import { Document, Event } from "../models";
|
||||
|
||||
export default async function documentUpdater({
|
||||
documentId,
|
||||
ydoc,
|
||||
userId,
|
||||
done,
|
||||
}: {
|
||||
documentId: string,
|
||||
ydoc: Y.Doc,
|
||||
userId: string,
|
||||
done?: boolean,
|
||||
}) {
|
||||
const document = await Document.findByPk(documentId);
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const node = yDocToProsemirror(schema, ydoc);
|
||||
const text = serializer.serialize(node);
|
||||
|
||||
// extract collaborators from doc user data
|
||||
const pud = new Y.PermanentUserData(ydoc);
|
||||
const pudIds = Array.from(pud.clients.values());
|
||||
const existingIds = document.collaboratorIds;
|
||||
const collaboratorIds = uniq([...pudIds, ...existingIds]);
|
||||
|
||||
if (document.text === text) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Document.update(
|
||||
{
|
||||
text,
|
||||
state: Buffer.from(state),
|
||||
updatedAt: new Date(),
|
||||
lastModifiedById: userId,
|
||||
collaboratorIds,
|
||||
},
|
||||
{
|
||||
hooks: false,
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const event = {
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: userId,
|
||||
data: {
|
||||
multiplayer: true,
|
||||
title: document.title,
|
||||
},
|
||||
};
|
||||
|
||||
if (done) {
|
||||
await Event.create(event);
|
||||
} else {
|
||||
await Event.add(event);
|
||||
}
|
||||
}
|
||||
@@ -41,13 +41,11 @@ 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}?ref=notification-email`}
|
||||
>
|
||||
<Button href={`${process.env.URL}${collection.url}`}>
|
||||
Open Collection
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// @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";
|
||||
@@ -17,7 +15,6 @@ export type Props = {
|
||||
document: Document,
|
||||
collection: Collection,
|
||||
eventName: string,
|
||||
summary: string,
|
||||
unsubscribeUrl: string,
|
||||
};
|
||||
|
||||
@@ -41,34 +38,26 @@ 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>
|
||||
{summary && (
|
||||
<>
|
||||
<EmptySpace height={20} />
|
||||
<Diff href={link}>
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} />
|
||||
</Diff>
|
||||
<EmptySpace height={20} />
|
||||
</>
|
||||
)}
|
||||
<hr />
|
||||
<EmptySpace height={10} />
|
||||
<p>{document.getSummary()}</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={link}>Open Document</Button>
|
||||
<Button href={`${team.url}${document.url}`}>Open Document</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
@@ -76,211 +65,3 @@ 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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const InviteEmail = ({
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${teamUrl}?ref=invite-email`}>Join now</Button>
|
||||
<Button href={teamUrl}>Join now</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -43,9 +43,7 @@ export const WelcomeEmail = ({ teamUrl }: Props) => {
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${teamUrl}/home?ref=welcome-email`}>
|
||||
View my dashboard
|
||||
</Button>
|
||||
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// @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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -35,7 +35,6 @@ export type DocumentEvent =
|
||||
name: | "documents.create" // eslint-disable-line
|
||||
| "documents.publish"
|
||||
| "documents.delete"
|
||||
| "documents.permanent_delete"
|
||||
| "documents.pin"
|
||||
| "documents.unpin"
|
||||
| "documents.archive"
|
||||
@@ -100,8 +99,6 @@ export type RevisionEvent = {
|
||||
documentId: string,
|
||||
collectionId: string,
|
||||
teamId: string,
|
||||
actorId: string,
|
||||
modelId: string,
|
||||
};
|
||||
|
||||
export type CollectionImportEvent = {
|
||||
|
||||
+12
-23
@@ -13,7 +13,6 @@ import {
|
||||
type Props as DocumentNotificationEmailT,
|
||||
DocumentNotificationEmail,
|
||||
documentNotificationEmailText,
|
||||
css as documentNotificationEmailCSS,
|
||||
} from "./emails/DocumentNotificationEmail";
|
||||
import { ExportEmail, exportEmailText } from "./emails/ExportEmail";
|
||||
import {
|
||||
@@ -147,9 +146,8 @@ export class Mailer {
|
||||
this.sendMail({
|
||||
to: opts.to,
|
||||
title: `“${opts.document.title}” ${opts.eventName}`,
|
||||
previewText: `${opts.actor.name} ${opts.eventName} a document`,
|
||||
previewText: `${opts.actor.name} ${opts.eventName} a new document`,
|
||||
html: <DocumentNotificationEmail {...opts} />,
|
||||
headCSS: documentNotificationEmailCSS,
|
||||
text: documentNotificationEmailText(opts),
|
||||
});
|
||||
};
|
||||
@@ -180,10 +178,6 @@ 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) {
|
||||
@@ -199,24 +193,19 @@ export class Mailer {
|
||||
|
||||
if (useTestEmailService) {
|
||||
log("SMTP_USERNAME not provided, generating test account…");
|
||||
let testAccount = await nodemailer.createTestAccount();
|
||||
|
||||
try {
|
||||
let testAccount = await nodemailer.createTestAccount();
|
||||
const smtpConfig = {
|
||||
host: "smtp.ethereal.email",
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: testAccount.user,
|
||||
pass: testAccount.pass,
|
||||
},
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
this.transporter = nodemailer.createTransport(smtpConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+168
-96
@@ -1,18 +1,21 @@
|
||||
// @flow
|
||||
import http from "http";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import debug from "debug";
|
||||
import IO from "socket.io";
|
||||
import socketRedisAdapter from "socket.io-redis";
|
||||
import SocketAuth from "socketio-auth";
|
||||
import app from "./app";
|
||||
import { Document, Collection, View } from "./models";
|
||||
import { Team, Document, Collection, View } from "./models";
|
||||
import * as multiplayer from "./multiplayer";
|
||||
import policy from "./policies";
|
||||
import { client, subscriber } from "./redis";
|
||||
import { getUserForJWT } from "./utils/jwt";
|
||||
import * 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;
|
||||
@@ -30,10 +33,6 @@ 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.`);
|
||||
@@ -43,22 +42,6 @@ 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;
|
||||
@@ -77,6 +60,7 @@ SocketAuth(io, {
|
||||
}
|
||||
},
|
||||
postAuthenticate: async (socket, data) => {
|
||||
log(`postAuthenticate ${socket.id}`);
|
||||
const { user } = socket.client;
|
||||
|
||||
// the rooms associated with the current team
|
||||
@@ -93,41 +77,121 @@ SocketAuth(io, {
|
||||
|
||||
// join all of the rooms at once
|
||||
socket.join(rooms);
|
||||
},
|
||||
});
|
||||
|
||||
// allow the client to request to join rooms
|
||||
socket.on("join", async (event) => {
|
||||
// user is joining a collection channel, because their permissions have
|
||||
// changed, granting them access.
|
||||
if (event.collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(event.collectionId);
|
||||
// receive multiplayer "sync" messages from other nodes (awareness and doc updates),
|
||||
// applies data to doc if in memory otherwise the request is ignored
|
||||
io.of("/").adapter.customHook = (event, callback) => {
|
||||
io.of("/").clients((err, socketIds) => {
|
||||
if (!socketIds.includes(event.socketId)) {
|
||||
multiplayer.handleRemoteSync(
|
||||
event.socketId,
|
||||
event.documentId,
|
||||
event.userId,
|
||||
event.data
|
||||
);
|
||||
}
|
||||
});
|
||||
callback(true);
|
||||
};
|
||||
|
||||
if (can(user, "read", collection)) {
|
||||
socket.join(`collection-${event.collectionId}`, () => {
|
||||
metrics.increment("websockets.collections.join");
|
||||
});
|
||||
io.on("connection", (socket) => {
|
||||
socket.on("sync", (event) => {
|
||||
if (!socket.auth) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = socket.client.user.id;
|
||||
|
||||
// handleJoin must be called before handleSync to ensure authentication
|
||||
// to communicate changes for the document/socket combo. Messages received
|
||||
// before handleJoin will be logged and discarded.
|
||||
multiplayer.handleSync(
|
||||
socket,
|
||||
event.documentId,
|
||||
userId,
|
||||
new Uint8Array(event.data)
|
||||
);
|
||||
|
||||
// forward "sync" messages to all nodes (awareness and doc updates) so
|
||||
// that any docs held in memory can be kept up to date.
|
||||
// TODO: optimize by proactively keeping track of which nodes have doc in
|
||||
// memory? Perf gains for large horizontal scaling.
|
||||
io.of("/").adapter.customRequest(
|
||||
{
|
||||
socketId: socket.id,
|
||||
documentId: event.documentId,
|
||||
userId,
|
||||
data: event.data,
|
||||
},
|
||||
(err) => {
|
||||
if (err) {
|
||||
log(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// user is joining a document channel, because they have navigated to
|
||||
// view a document.
|
||||
if (event.documentId) {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
// allow the client to request to join rooms
|
||||
socket.on("join", async (event) => {
|
||||
if (!socket.auth) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (can(user, "read", document)) {
|
||||
const room = `document-${event.documentId}`;
|
||||
log("join", event.documentId, socket.id);
|
||||
const { user } = socket.client;
|
||||
|
||||
await View.touch(event.documentId, user.id, event.isEditing);
|
||||
// user is joining a collection channel, because their permissions have
|
||||
// changed, granting them access.
|
||||
if (event.collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(event.collectionId);
|
||||
|
||||
if (can(user, "read", collection)) {
|
||||
socket.join(`collection-${event.collectionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// user is joining a document channel, because they have navigated to
|
||||
// view a document.
|
||||
if (event.documentId) {
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (can(user, "read", document)) {
|
||||
const room = `document-${event.documentId}`;
|
||||
|
||||
// new logic for multiplayer editing completely changes "presence"
|
||||
// detection and propagation, so split at a high-level here.
|
||||
if (team.multiplayerEditor) {
|
||||
log("joined multiplayer", socket.id);
|
||||
socket.join(room, () => {
|
||||
socket.emit("user.join", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
});
|
||||
|
||||
multiplayer.handleJoin({
|
||||
io,
|
||||
document,
|
||||
socket: socket,
|
||||
documentId: event.documentId,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// old deprecated logic to be removed in the future once multiplayer
|
||||
// has stabilized
|
||||
|
||||
await View.touch(event.documentId, user.id);
|
||||
const editing = await View.findRecentlyEditingByDocument(
|
||||
event.documentId
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -136,11 +200,11 @@ SocketAuth(io, {
|
||||
});
|
||||
|
||||
// let this user know who else is already present in the room
|
||||
io.in(room).clients(async (err, sockets) => {
|
||||
io.in(room).clients(async (err, socketIds) => {
|
||||
if (err) {
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.withScope(function (scope) {
|
||||
scope.setExtra("clients", sockets);
|
||||
scope.setExtra("clients", socketIds);
|
||||
Sentry.captureException(err);
|
||||
});
|
||||
} else {
|
||||
@@ -153,7 +217,7 @@ SocketAuth(io, {
|
||||
// need to make sure that only unique userIds are returned. A Map
|
||||
// makes this easy.
|
||||
let userIds = new Map();
|
||||
for (const socketId of sockets) {
|
||||
for (const socketId of socketIds) {
|
||||
const userId = await client.hget(socketId, "userId");
|
||||
userIds.set(userId, userId);
|
||||
}
|
||||
@@ -166,63 +230,71 @@ SocketAuth(io, {
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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");
|
||||
// allow the client to request to leave rooms
|
||||
socket.on("leave", (event) => {
|
||||
if (!socket.auth) {
|
||||
return;
|
||||
}
|
||||
|
||||
io.to(room).emit("user.leave", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnecting", () => {
|
||||
const rooms = Object.keys(socket.rooms);
|
||||
|
||||
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");
|
||||
if (event.collectionId) {
|
||||
socket.leave(`collection-${event.collectionId}`);
|
||||
}
|
||||
|
||||
if (event.documentId) {
|
||||
const room = `document-${event.documentId}`;
|
||||
const userId = socket.client.user.id;
|
||||
|
||||
if (event.documentId && socket.rooms[room]) {
|
||||
const view = await View.touch(
|
||||
event.documentId,
|
||||
user.id,
|
||||
event.isEditing
|
||||
);
|
||||
view.user = user;
|
||||
|
||||
io.to(room).emit("user.presence", {
|
||||
userId: user.id,
|
||||
socket.leave(room, () => {
|
||||
io.to(room).emit("user.leave", {
|
||||
userId,
|
||||
documentId: event.documentId,
|
||||
isEditing: event.isEditing,
|
||||
});
|
||||
});
|
||||
|
||||
multiplayer.handleLeave(socket.id, userId, event.documentId);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnecting", () => {
|
||||
if (!socket.auth) {
|
||||
return;
|
||||
}
|
||||
const rooms = Object.keys(socket.rooms);
|
||||
|
||||
rooms.forEach((room) => {
|
||||
if (room.startsWith("document-")) {
|
||||
const documentId = room.replace("document-", "");
|
||||
const userId = socket.client.user.id;
|
||||
io.to(room).emit("user.leave", {
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
multiplayer.handleLeave(socket.id, userId, documentId);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
socket.on("presence", async (event) => {
|
||||
if (!socket.auth) {
|
||||
return;
|
||||
}
|
||||
const room = `document-${event.documentId}`;
|
||||
const { user } = socket.client;
|
||||
|
||||
if (event.documentId && socket.rooms[room]) {
|
||||
const view = await View.touch(event.documentId, user.id, event.isEditing);
|
||||
view.user = user;
|
||||
|
||||
io.to(room).emit("user.presence", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
isEditing: event.isEditing,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function createMiddleware(providerName: string) {
|
||||
return passport.authorize(
|
||||
providerName,
|
||||
{ session: false },
|
||||
async (err, user, result: AccountProvisionerResult) => {
|
||||
async (err, _, result: AccountProvisionerResult) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -24,14 +24,6 @@ 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,7 +1,15 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeConstraint('users', 'users_email_key', {})
|
||||
await queryInterface.removeConstraint('users', 'users_username_key', {})
|
||||
await queryInterface.changeColumn('users', 'email', {
|
||||
type: Sequelize.STRING,
|
||||
unique: false,
|
||||
allowNull: false,
|
||||
});
|
||||
await queryInterface.changeColumn('users', 'username', {
|
||||
type: Sequelize.STRING,
|
||||
unique: false,
|
||||
allowNull: false,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('documents', 'state', {
|
||||
type: Sequelize.BLOB
|
||||
});
|
||||
await queryInterface.addColumn('teams', 'multiplayerEditor', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('documents', 'state');
|
||||
await queryInterface.removeColumn('teams', 'multiplayerEditor');
|
||||
}
|
||||
};
|
||||
@@ -395,11 +395,7 @@ Collection.prototype.isChildDocument = function (
|
||||
let result = false;
|
||||
|
||||
const loopChildren = (documents, input) => {
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
|
||||
documents.forEach((document) => {
|
||||
return documents.map((document) => {
|
||||
let parents = [...input];
|
||||
if (document.id === documentId) {
|
||||
result = parents.includes(parentDocumentId);
|
||||
@@ -407,6 +403,7 @@ Collection.prototype.isChildDocument = function (
|
||||
parents.push(document.id);
|
||||
loopChildren(document.children, parents);
|
||||
}
|
||||
return document;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ const Document = sequelize.define(
|
||||
template: DataTypes.BOOLEAN,
|
||||
editorVersion: DataTypes.STRING,
|
||||
text: DataTypes.TEXT,
|
||||
state: DataTypes.BLOB,
|
||||
|
||||
// backup contains a record of text at the moment it was converted to v2
|
||||
// this is a safety measure during deployment of new editor and will be
|
||||
|
||||
@@ -65,7 +65,6 @@ Event.ACTIVITY_EVENTS = [
|
||||
"documents.unpin",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
"documents.restore",
|
||||
"users.create",
|
||||
];
|
||||
@@ -91,7 +90,6 @@ Event.AUDIT_EVENTS = [
|
||||
"documents.unpin",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
"documents.restore",
|
||||
"groups.create",
|
||||
"groups.update",
|
||||
|
||||
@@ -15,9 +15,6 @@ const SearchQuery = sequelize.define(
|
||||
},
|
||||
query: {
|
||||
type: DataTypes.STRING,
|
||||
set(val) {
|
||||
this.setDataValue("query", val.substring(0, 255));
|
||||
},
|
||||
allowNull: false,
|
||||
},
|
||||
results: {
|
||||
|
||||
@@ -69,6 +69,11 @@ const Team = sequelize.define(
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
multiplayerEditor: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
paranoid: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { languages } from "../../shared/i18n";
|
||||
import { ValidationError } from "../errors";
|
||||
import { DataTypes, sequelize, encryptedFields, Op } from "../sequelize";
|
||||
import { DEFAULT_AVATAR_HOST } from "../utils/avatars";
|
||||
import { palette } from "../utils/color";
|
||||
import { publicS3Endpoint, uploadToS3FromUrl } from "../utils/s3";
|
||||
import {
|
||||
UserAuthentication,
|
||||
@@ -74,6 +75,11 @@ const User = sequelize.define(
|
||||
.digest("hex");
|
||||
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png`;
|
||||
},
|
||||
color() {
|
||||
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
|
||||
const idAsNumber = parseInt(idAsHex, 16);
|
||||
return palette[idAsNumber % palette.length];
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// @flow
|
||||
import * as encoding from "lib0/dist/encoding.cjs";
|
||||
import * as mutex from "lib0/dist/mutex.cjs";
|
||||
import { parser } from "rich-markdown-editor";
|
||||
import { prosemirrorToYDoc } from "y-prosemirror";
|
||||
import * as awarenessProtocol from "y-protocols/dist/awareness.cjs";
|
||||
import * as syncProtocol from "y-protocols/dist/sync.cjs";
|
||||
import * as Y from "yjs";
|
||||
import { MESSAGE_AWARENESS, MESSAGE_SYNC } from "../../shared/constants";
|
||||
import { Document } from "../models";
|
||||
|
||||
export default class WSSharedDoc extends Y.Doc {
|
||||
constructor(document: Document, io: any) {
|
||||
super({ gc: true });
|
||||
this.io = io;
|
||||
this.documentId = document.id;
|
||||
this.mux = mutex.createMutex();
|
||||
this.conns = new Map();
|
||||
this.awareness = new awarenessProtocol.Awareness(this);
|
||||
this.awareness.setLocalState(null);
|
||||
|
||||
if (document.state) {
|
||||
Y.applyUpdate(this, document.state);
|
||||
} else {
|
||||
const node = parser.parse(document.text);
|
||||
const ydoc = prosemirrorToYDoc(node);
|
||||
Y.applyUpdate(this, Y.encodeStateAsUpdate(ydoc));
|
||||
}
|
||||
|
||||
this.awareness.on("update", this.awarenessHandler);
|
||||
this.on("update", this.updateHandler);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.off("update", this.updateHandler);
|
||||
this.awareness.off("update", this.awarenessHandler);
|
||||
this.awareness.destroy();
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
awarenessHandler = (
|
||||
{
|
||||
added,
|
||||
updated,
|
||||
removed,
|
||||
}: { added: Array<number>, updated: Array<number>, removed: Array<number> },
|
||||
socketId: number
|
||||
) => {
|
||||
const changedClients = added.concat(updated, removed);
|
||||
|
||||
if (socketId !== null) {
|
||||
const connControlledIDs = this.conns.get(socketId);
|
||||
|
||||
if (connControlledIDs !== undefined) {
|
||||
added.forEach((clientID) => {
|
||||
connControlledIDs.add(clientID);
|
||||
});
|
||||
removed.forEach((clientID) => {
|
||||
connControlledIDs.delete(clientID);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast awareness update
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients)
|
||||
);
|
||||
const data = encoding.toUint8Array(encoder);
|
||||
|
||||
this.io
|
||||
.to(`document-${this.documentId}`)
|
||||
.binary(true)
|
||||
.emit("document.sync", {
|
||||
documentId: this.documentId,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
updateHandler = (update: Uint8Array, origin: any) => {
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
||||
syncProtocol.writeUpdate(encoder, update);
|
||||
const data = encoding.toUint8Array(encoder);
|
||||
|
||||
this.io
|
||||
.to(`document-${this.documentId}`)
|
||||
.binary(true)
|
||||
.emit("document.sync", {
|
||||
documentId: this.documentId,
|
||||
data,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// @flow
|
||||
import debug from "debug";
|
||||
import * as decoding from "lib0/dist/decoding.cjs";
|
||||
import * as encoding from "lib0/dist/encoding.cjs";
|
||||
import { debounce } from "lodash";
|
||||
import { Socket } from "socket.io-client";
|
||||
import * as awarenessProtocol from "y-protocols/dist/awareness.cjs";
|
||||
import * as syncProtocol from "y-protocols/dist/sync.cjs";
|
||||
import * as Y from "yjs";
|
||||
import { MESSAGE_AWARENESS, MESSAGE_SYNC } from "../../shared/constants";
|
||||
import documentUpdater from "../commands/documentUpdater";
|
||||
import { Document } from "../models";
|
||||
import WSSharedDoc from "./WSSharedDoc";
|
||||
|
||||
const log = debug("multiplayer");
|
||||
const docs = new Map<string, WSSharedDoc>();
|
||||
const PERSIST_WAIT = 3000;
|
||||
|
||||
export function handleJoin({
|
||||
io,
|
||||
socket,
|
||||
document,
|
||||
documentId,
|
||||
}: {
|
||||
io: any,
|
||||
socket: Socket,
|
||||
document: Document,
|
||||
documentId: string,
|
||||
}) {
|
||||
log(`socket ${socket.id} is joining ${documentId}`);
|
||||
let doc = docs.get(documentId);
|
||||
|
||||
if (!doc) {
|
||||
doc = new WSSharedDoc(document, io);
|
||||
doc.get("prosemirror", Y.XmlFragment);
|
||||
|
||||
if (document.state) {
|
||||
log(`no existing session for ${documentId} – using database state`);
|
||||
Y.applyUpdate(doc, document.state);
|
||||
} else {
|
||||
log(`no existing session for ${documentId} – no database state`);
|
||||
}
|
||||
|
||||
doc.on(
|
||||
"update",
|
||||
debounce(
|
||||
async (update, origin: { userId: string, remote?: boolean }) => {
|
||||
// If the origin is "remote" this means that the transaction came from
|
||||
// a remote server process, as we're just accepting transactions to
|
||||
// keep us in sync with another doc there is no need to persist.
|
||||
if (origin.remote) {
|
||||
return;
|
||||
}
|
||||
|
||||
log(`persisting doc (${documentId}) to database`);
|
||||
await documentUpdater({
|
||||
documentId,
|
||||
ydoc: doc,
|
||||
userId: origin.userId,
|
||||
});
|
||||
},
|
||||
PERSIST_WAIT,
|
||||
{
|
||||
maxWait: PERSIST_WAIT * 3,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
docs.set(documentId, doc);
|
||||
}
|
||||
|
||||
doc.conns.set(socket.id, new Set());
|
||||
|
||||
// send sync step 1
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
||||
syncProtocol.writeSyncStep1(encoder, doc);
|
||||
|
||||
socket.binary(true).emit("document.sync", {
|
||||
documentId,
|
||||
data: encoding.toUint8Array(encoder),
|
||||
});
|
||||
|
||||
const awarenessStates = doc.awareness.getStates();
|
||||
|
||||
if (awarenessStates.size > 0) {
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(
|
||||
doc.awareness,
|
||||
Array.from(awarenessStates.keys())
|
||||
)
|
||||
);
|
||||
|
||||
socket.binary(true).emit("document.sync", {
|
||||
documentId,
|
||||
data: encoding.toUint8Array(encoder),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLeave(
|
||||
socketId: string,
|
||||
userId: string,
|
||||
documentId: string
|
||||
) {
|
||||
let doc = docs.get(documentId);
|
||||
|
||||
// this method is called for all leave events, even old-style, so it needs
|
||||
// to handle attempting to leave when there is no existing connection
|
||||
if (!doc || !doc.conns.has(socketId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove ourselves from the awareness state
|
||||
const controlledIds = doc.conns.get(socketId);
|
||||
doc.conns.delete(socketId);
|
||||
awarenessProtocol.removeAwarenessStates(
|
||||
doc.awareness,
|
||||
Array.from(controlledIds),
|
||||
null
|
||||
);
|
||||
|
||||
// last client has left this document connection, time to cleanup and ensure
|
||||
// we've written the latest state to the database.
|
||||
|
||||
// Important note: In multi-server setups this can mean that everyone has left
|
||||
// on an individual server process, however their may still be other clients
|
||||
// connected to other processes
|
||||
// TODO: store connections in redis?
|
||||
if (doc.conns.size === 0) {
|
||||
log(`all clients left doc (${documentId}), persisting…`);
|
||||
await documentUpdater({ documentId, ydoc: doc, userId, done: true });
|
||||
|
||||
doc.destroy();
|
||||
docs.delete(documentId);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleSync(
|
||||
socket: Socket,
|
||||
documentId: string,
|
||||
userId: string,
|
||||
message: Uint8Array
|
||||
) {
|
||||
// check auth with existence of socketId in set
|
||||
let doc = docs.get(documentId);
|
||||
if (!doc) {
|
||||
log(`received sync message but doc (${documentId}) not yet loaded`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doc.conns.get(socket.id)) {
|
||||
log(
|
||||
`received sync message but socket (${socket.id}) has not joined doc (${documentId})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const encoder = encoding.createEncoder();
|
||||
const decoder = decoding.createDecoder(message);
|
||||
const messageType = decoding.readVarUint(decoder);
|
||||
|
||||
switch (messageType) {
|
||||
case MESSAGE_SYNC: {
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
||||
syncProtocol.readSyncMessage(decoder, encoder, doc, { userId });
|
||||
if (encoding.length(encoder) > 1) {
|
||||
socket.binary(true).emit("document.sync", {
|
||||
documentId,
|
||||
data: encoding.toUint8Array(encoder),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MESSAGE_AWARENESS: {
|
||||
awarenessProtocol.applyAwarenessUpdate(
|
||||
doc.awareness,
|
||||
decoding.readVarUint8Array(decoder),
|
||||
socket.id
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
export function handleRemoteSync(
|
||||
socketId: string,
|
||||
documentId: string,
|
||||
userId: string,
|
||||
data: {
|
||||
type: string,
|
||||
data: ArrayBuffer,
|
||||
}
|
||||
) {
|
||||
let doc = docs.get(documentId);
|
||||
if (!doc) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
log(`received remote sync message but doc (${documentId}) not loaded`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doc.conns.get(socketId)) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
log(
|
||||
`received remote sync message but socket (${socketId}) has not joined doc (${documentId})`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: This is different to handleSync – parsing moved to here so that we
|
||||
// can avoid conversion steps if the doc doesn't already exist in memory.
|
||||
const message = new Uint8Array(Buffer.from(data.data));
|
||||
const encoder = encoding.createEncoder();
|
||||
const decoder = decoding.createDecoder(message);
|
||||
const messageType = decoding.readVarUint(decoder);
|
||||
|
||||
switch (messageType) {
|
||||
case MESSAGE_SYNC: {
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
||||
syncProtocol.readSyncMessage(decoder, encoder, doc, {
|
||||
userId,
|
||||
remote: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case MESSAGE_AWARENESS: {
|
||||
awarenessProtocol.applyAwarenessUpdate(
|
||||
doc.awareness,
|
||||
decoding.readVarUint8Array(decoder),
|
||||
socketId
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -101,15 +101,8 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "delete", 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;
|
||||
}
|
||||
|
||||
// unpublished drafts can always be deleted
|
||||
if (user.isViewer) return false;
|
||||
if (
|
||||
!document.deletedAt &&
|
||||
!document.publishedAt &&
|
||||
@@ -118,18 +111,13 @@ 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,6 +3,7 @@
|
||||
exports[`presents a user 1`] = `
|
||||
Object {
|
||||
"avatarUrl": undefined,
|
||||
"color": undefined,
|
||||
"createdAt": undefined,
|
||||
"id": "123",
|
||||
"isAdmin": undefined,
|
||||
@@ -16,6 +17,7 @@ Object {
|
||||
exports[`presents a user without slack data 1`] = `
|
||||
Object {
|
||||
"avatarUrl": undefined,
|
||||
"color": undefined,
|
||||
"createdAt": undefined,
|
||||
"id": "123",
|
||||
"isAdmin": undefined,
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function present(team: Team) {
|
||||
sharing: team.sharing,
|
||||
documentEmbeds: team.documentEmbeds,
|
||||
guestSignin: team.guestSignin,
|
||||
multiplayerEditor: team.multiplayerEditor,
|
||||
subdomain: team.subdomain,
|
||||
domain: team.domain,
|
||||
url: team.url,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user