Compare commits

...

62 Commits

Author SHA1 Message Date
hmacr 12013f79fb non-modal menu 2025-01-04 06:09:29 +05:30
hmacr bfa32133f6 change z-index and remove shrink 2025-01-04 06:08:39 +05:30
hmacr ca662b0f38 full page scroll 2025-01-03 20:54:17 +05:30
hmacr dc7f712558 share query options 2024-12-30 13:56:50 +05:30
hmacr f014cc91d4 container height 2024-12-30 13:21:40 +05:30
hmacr 50b083f4af width in column def 2024-12-30 05:54:14 +05:30
hmacr 0e043888ac Upgrade and virtualize table component 2024-12-27 13:55:50 +05:30
Tom Moor d66f41c854 fix: Improve behavior of LaTeX at small screensizes, closes #8032 2024-11-29 11:20:01 -05:00
Tom Moor b2d6c40ea8 chore: Add warning for problematic selfhosted config, closes #8025 2024-11-29 11:07:23 -05:00
infinite-persistence c98d6aa33a Allow user to select doc-copy destination (#8030)
* DocumentExplorer: make style extensible

* Allow user to select doc-copy dest

The change in `documentDuplicator` essentially alters the fallback from "parent" to "top of collection". But there is only 1 place that uses it so far, so I think it's fine to support this PR.

In the next commit, the caller side will restore the default to "parent".

* Auto select parent as initial target (to retain existing behavior)

Otherwise, user would need to always search/expand the tree. I have a feeling that people might want the last selection to be persistent, but ignoring that for now.

The 50ms timeout feels dirty, but 0 was too fast, at least on my machine. I couldn't find anything in react-window for a "ready" flag.

* Rename: DuplicateDialog -> DocumentCopy

This begins the switch to DocumentCopy's look in the next few commits

* Revert DocumentExplorer style override

No longer needed since we won't be using it under a ConfirmationDialog anymore in the next commit.

* Switch to DocumentMove's style

* initialSelectionId -> defaultValue

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-11-29 10:33:38 -05:00
Hemachandar 554c2a5cdb Simplify determining from email address (#8039)
* Simplify determining from email address

* override only for cloud-hosted
2024-11-29 06:41:48 -08:00
Hemachandar ee426de942 Cleanup random text (#8036) 2024-11-29 08:14:59 -05:00
Tom Moor 746e65e658 fix: Recursively filter source document from explorer, closes #8028 2024-11-27 23:04:37 -05:00
Tom Moor 8a3a3453e7 fix: The operation was unable to achieve a quorum during its retry window 2024-11-27 23:04:37 -05:00
Tom Moor c7d339ded5 Tracking of total uploaded attachments / team (#8031)
* Add column and task to calculate size

* Store in MB, rather than Bytes

* Add cron task to recalculate attachment sizes

* findAllInBatches

* Index createdAt

* fix: Index on incorrect table
2024-11-27 18:42:23 -08:00
Tom Moor ed25554607 fix: Hide TOC on templates 2024-11-27 18:20:49 -05:00
Tom Moor 29329daf15 chore: Record on users.signin event 2024-11-27 17:59:46 -05:00
Tom Moor 3f6390ff18 chore: Remove error on double reload 2024-11-27 17:56:02 -05:00
Translate-O-Tron 54b43c6e6f New Crowdin updates (#8029)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2024-11-27 08:38:31 -08:00
Tom Moor 8c9c83eb5a fix: Improve contrast on context menus in dark mode 2024-11-27 10:16:22 -05:00
Tom Moor 63171e5da2 fix: Incorrect cursor on sortable table headers 2024-11-27 09:33:52 -05:00
Tom Moor bfd84681d7 fix: Jank in domain management screen 2024-11-26 22:29:26 -05:00
Tom Moor 7d6a47ce86 chore: Remove unused undo/redo methods 2024-11-26 20:53:44 -05:00
Tom Moor 68f715b607 chore: Remove unused typing tracking logic 2024-11-26 20:50:57 -05:00
Tom Moor ea2e7a4d0f chore: Remove duplicate ID annotations 2024-11-26 20:43:01 -05:00
Translate-O-Tron 26948af1b8 New Crowdin updates (#7967) 2024-11-26 17:24:29 -08:00
Tom Moor 816a6715c5 chore: Simplify comment sidebar persistence to be per-user (#8022) 2024-11-26 17:24:07 -08:00
Tom Moor 4579594c63 fix: Relayout jank on document references 2024-11-26 09:05:14 -05:00
Tom Moor 88f7705fd4 fix: Starred documents do not expand when focusing, related #7956 2024-11-25 23:30:01 -05:00
Hemachandar 8393847910 Check flag emoji is supported (#8009) 2024-11-25 19:32:41 -08:00
dependabot[bot] b9adfa175d chore(deps-dev): bump @types/readable-stream from 4.0.15 to 4.0.18 (#8019)
Bumps [@types/readable-stream](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/readable-stream) from 4.0.15 to 4.0.18.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/readable-stream)

---
updated-dependencies:
- dependency-name: "@types/readable-stream"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-25 19:31:39 -08:00
dependabot[bot] 7fff8161ff chore(deps): bump vite from 5.4.10 to 5.4.11 (#8021)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.10 to 5.4.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.11/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-25 19:31:30 -08:00
Tom Moor 0ef9f1aea1 fix: Improve fast-click functionality in sidebar 2024-11-25 22:22:21 -05:00
Tom Moor fe63c5d706 fix: JS error in usePosition hook 2024-11-25 20:42:31 -05:00
Tom Moor 7749f0ab9f fix: Undo/redo regression 2024-11-25 20:36:23 -05:00
Tom Moor 763b911dfd fix: Named commands broken, regressed in 921e89d7b7 2024-11-24 23:34:48 -05:00
Tom Moor 99e541ede8 fix: Ensure logout OIDC never immediately relogin 2024-11-24 22:34:16 -05:00
Tom Moor 06f48ec79a Add active/hover state to collapsed thread 2024-11-24 19:59:31 -05:00
infinite-persistence 5566d995bd Comment: collapse long replies (#7941)
* Comment: collapse long replies

## Ticket
Closes 5079

## Review
- For the case of RTL, followed how "Reply" is implemented (assumed that is the desired). If it need to be re-aligned, it can be fixed together with "Reply" later.
- The threshold number can be moved to constants.ts if we don't want to pollute the props.

* Card-style + Facepile
2024-11-24 16:20:46 -08:00
Tom Moor 921e89d7b7 fix: Undo/redo behavior incorrect in multiplayer editor (#8015) 2024-11-24 16:19:52 -08:00
Tom Moor 32602f89dd fix: Flash of styles when printing dark mode (#8010) 2024-11-24 06:15:34 -08:00
Tom Moor 2cce95488c fix: S3 expiry not passed correctly (#8013) 2024-11-24 06:15:19 -08:00
Tom Moor 0663d191fc fix: Lists with negative margin are cut off when printing to PDF. This is a pragmatic fix for the issue closes #7958 2024-11-23 12:00:05 -05:00
Tom Moor 84eb1b801d fix: 'Replace all' functionality replacing offset incorrectly 2024-11-23 00:47:26 -05:00
Hemachandar 5102cfe8eb Persist theme after update (#7997) 2024-11-21 05:18:11 -05:00
Tom Moor 1d0617dbd6 fix: Edge case where heading in first table cell changes margin on focus 2024-11-20 20:52:11 -05:00
Tom Moor eedfd549b3 fix: Rare loop of storage events between tabs causing flickering UI (#7996)
* fix: Rare loop of storage events between tabs causing flickering UI

* Types cleanup
2024-11-20 16:17:28 -08:00
Hemachandar 28cb5aa379 Convert pin mutations to use auto event insertion (#7993) 2024-11-20 16:14:11 -08:00
Tom Moor fd5391cbb6 Cache diff generation for email notifications (#7987)
* Cache diff generation, closes #7982

* Handle cannot acquire lock

* Refactor to guard
2024-11-20 14:45:12 -08:00
Hemachandar 6e685ee8d9 store pin location on mount (#7994) 2024-11-20 14:27:16 -08:00
Tom Moor b595a0d427 fix: No-op sending emails in self-hosted if configuration is unavailable rather than retrying
towards #7982
2024-11-19 20:45:32 -05:00
Tom Moor 1c86119065 fix: Cannot sort by role on member settings, closes #7986 2024-11-19 19:22:53 -05:00
Tom Moor c629006642 Add inline resolve action on comment threads (#7977)
* Add inline resolve action on comment threads

* perf refactor
2024-11-18 18:36:44 -08:00
Tom Moor 326f733d4c fix: Further improvements to diacritics matching in CMD+F 2024-11-18 18:04:10 -05:00
Tom Moor d4d683c046 fix: Missing space character in invite modal, related #7968 2024-11-18 17:51:49 -05:00
Tom Moor 8204ac343f chore: Upgrade Sentry/AWS 2024-11-18 17:48:36 -05:00
dependabot[bot] cae8de7c7a chore(deps): bump @octokit/auth-app from 6.1.2 to 6.1.3 (#7974)
Bumps [@octokit/auth-app](https://github.com/octokit/auth-app.js) from 6.1.2 to 6.1.3.
- [Release notes](https://github.com/octokit/auth-app.js/releases)
- [Commits](https://github.com/octokit/auth-app.js/compare/v6.1.2...v6.1.3)

---
updated-dependencies:
- dependency-name: "@octokit/auth-app"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-18 14:41:05 -08:00
dependabot[bot] 8efa601967 chore(deps-dev): bump @relative-ci/agent from 4.2.12 to 4.2.13 (#7975)
Bumps [@relative-ci/agent](https://github.com/relative-ci/agent) from 4.2.12 to 4.2.13.
- [Release notes](https://github.com/relative-ci/agent/releases)
- [Commits](https://github.com/relative-ci/agent/compare/v4.2.12...v4.2.13)

---
updated-dependencies:
- dependency-name: "@relative-ci/agent"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-18 14:40:53 -08:00
Tom Moor 86c3ea8e9d fix: Copy toolbar positioning 2024-11-17 10:00:52 -05:00
Tom Moor c222782534 Upgrade Mermaid script in exported HTML, related #7964 2024-11-17 09:51:46 -05:00
Tom Moor 19ea7ee52b Remove sourcemap generation in bundle size calc (#7966) 2024-11-16 16:57:19 -08:00
Tom Moor d1de84a07e Reduce build time (#7965)
* Test using xlarge

* wip

* wip
2024-11-16 10:45:14 -08:00
134 changed files with 2800 additions and 2383 deletions
+8 -6
View File
@@ -4,12 +4,6 @@ defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
resource_class: large
environment:
NODE_ENV: test
@@ -78,6 +72,14 @@ jobs:
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
+3 -3
View File
@@ -45,7 +45,7 @@ import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DuplicateDialog from "~/components/DuplicateDialog";
import DocumentCopy from "~/components/DocumentCopy";
import Icon from "~/components/Icon";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
@@ -562,7 +562,7 @@ export const duplicateDocument = createAction({
stores.dialogs.openModal({
title: t("Copy document"),
content: (
<DuplicateDialog
<DocumentCopy
document={document}
onSubmit={(response) => {
stores.dialogs.closeAllModals();
@@ -1054,7 +1054,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.toggleComments(activeDocumentId);
stores.ui.toggleComments();
},
});
-1
View File
@@ -31,7 +31,6 @@ const Actions = styled(Flex)`
left: 0;
border-radius: 3px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
padding: 12px;
backdrop-filter: blur(20px);
+2 -1
View File
@@ -5,6 +5,7 @@ import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
export default observer(Authenticated);
+1 -1
View File
@@ -94,7 +94,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded.includes(ui.activeDocumentId) &&
ui.commentsExpanded &&
team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
-1
View File
@@ -201,7 +201,6 @@ const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
transition: ${s("backgroundTransition")};
&:after {
content: "";
-1
View File
@@ -182,7 +182,6 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
const Content = styled.span`
background: ${s("background")};
transition: ${s("backgroundTransition")};
color: ${s("text")};
-webkit-text-fill-color: ${s("text")};
outline: none;
-16
View File
@@ -262,22 +262,6 @@ export const Position = styled.div`
transition-property: outline-width;
transition-duration: 0;
outline: none;
&:after {
content: "";
position: absolute;
top: 1px;
left: 1px;
right: 1px;
bottom: 1px;
pointer-events: none;
border-radius: 4px;
outline-color: ${s("accent")};
outline-width: initial;
outline-offset: -1px;
outline-style: solid;
}
}
/*
+3 -2
View File
@@ -39,6 +39,7 @@ function DocumentCard(props: Props) {
const { collections } = useStores();
const theme = useTheme();
const { document, pin, canUpdatePin, isDraggable } = props;
const pinnedToHome = React.useRef(!pin?.collectionId).current;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -122,13 +123,13 @@ function DocumentCard(props: Props) {
<Squircle
color={
collection?.color ??
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
(pinnedToHome ? theme.slateLight : theme.slateDark)
}
>
{collection?.icon &&
collection?.icon !== "letter" &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
pinnedToHome ? (
<CollectionIcon collection={collection} color="white" />
) : (
<DocumentIcon color="white" />
+149
View File
@@ -0,0 +1,149 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { flattenTree } from "~/utils/tree";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
document: Document;
onSubmit: (documents: Document[]) => void;
};
function DocumentCopy({ document, onSubmit }: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const collectionTrees = useCollectionTrees();
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
null
);
const items = React.useMemo(() => {
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const copy = async () => {
if (!selectedPath) {
toast.message(t("Select a location to copy"));
return;
}
try {
const result = await document.duplicate({
publish,
recursive,
title: document.title,
collectionId: selectedPath.collectionId,
...(selectedPath.type === "document"
? { parentDocumentId: selectedPath.id }
: {}),
});
toast.success(t("Document copied"));
onSubmit(result);
} catch (err) {
toast.error(t("Couldnt copy the document, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={copy}
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
<OptionsContainer>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
values={{ location: selectedPath.title }}
components={{ em: <strong /> }}
/>
) : (
t("Select a location to copy")
)}
</StyledText>
<Button disabled={!selectedPath} onClick={copy}>
{t("Copy")}
</Button>
</Footer>
</FlexContainer>
);
}
const OptionsContainer = styled.div`
margin: 16px 0 8px 0;
padding-left: 24px;
padding-right: 24px;
`;
export default observer(DocumentCopy);
+27 -5
View File
@@ -31,15 +31,15 @@ import { ancestors, descendants } from "~/utils/tree";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
onSubmit: () => void;
/** A side-effect of item selection */
onSelect: (item: NavigationNode | null) => void;
/** Items to be shown in explorer */
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
};
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -47,12 +47,25 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
null
() => {
const node =
defaultValue && items.find((item) => item.id === defaultValue);
return node || null;
}
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
const node = items.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((node) => node.id);
}
}
return [];
});
const [itemRefs, setItemRefs] = React.useState<
React.RefObject<HTMLSpanElement>[]
>([]);
@@ -94,6 +107,15 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, []);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
-97
View File
@@ -1,97 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { DocumentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input from "./Input";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
document: Document;
onSubmit: (documents: Document[]) => void;
};
function DuplicateDialog({ document, onSubmit }: Props) {
const { t } = useTranslation();
const defaultTitle = t(`Copy of {{ documentName }}`, {
documentName: document.title,
});
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [title, setTitle] = React.useState<string>(defaultTitle);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const handleTitleChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setTitle(ev.target.value);
},
[]
);
const handleSubmit = async () => {
const result = await document.duplicate({
publish,
recursive,
title,
});
onSubmit(result);
};
return (
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Duplicate")}>
<Input
autoFocus
autoSelect
name="title"
label={t("Title")}
onChange={handleTitleChange}
maxLength={DocumentValidation.maxTitleLength}
defaultValue={defaultTitle}
/>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</ConfirmationDialog>
);
}
export default observer(DuplicateDialog);
+2 -2
View File
@@ -46,7 +46,7 @@ const FilterOptions = ({
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const menu = useMenuState({
modal: true,
modal: false,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
${Outline} {
border: none;
border-radius: 0;
border-bottom: 1px solid ${s("inputBorder")};
border-bottom: 1px solid rgb(34 40 52);
background: ${s("menuBackground")};
}
-1
View File
@@ -94,7 +94,6 @@ const Scene = styled.div`
align-items: flex-start;
width: 350px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
border-radius: 8px;
outline: none;
opacity: 0;
-1
View File
@@ -130,7 +130,6 @@ const Wrapper = styled(Flex)<WrapperProps>`
`};
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
min-height: ${HEADER_HEIGHT}px;
justify-content: flex-start;
+4 -2
View File
@@ -47,14 +47,16 @@ export default function LanguagePrompt() {
<br />
<Link
onClick={async () => {
ui.setLanguagePromptDismissed();
ui.set({ languagePromptDismissed: true });
await user.save({ language });
}}
>
{t("Change Language")}
</Link>{" "}
&middot;{" "}
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
<Link onClick={() => ui.set({ languagePromptDismissed: true })}>
{t("Dismiss")}
</Link>
</span>
</Flex>
</Wrapper>
-1
View File
@@ -76,7 +76,6 @@ const Layout = React.forwardRef(function Layout_(
const Container = styled(Flex)`
background: ${s("background")};
transition: ${s("backgroundTransition")};
position: relative;
width: 100%;
min-height: 100%;
-2
View File
@@ -174,7 +174,6 @@ const Fullscreen = styled.div<FullscreenProps>`
justify-content: center;
align-items: flex-start;
background: ${s("background")};
transition: ${s("backgroundTransition")};
outline: none;
${breakpoint("tablet")`
@@ -265,7 +264,6 @@ const Small = styled.div`
justify-content: center;
align-items: flex-start;
background: ${s("modalBackground")};
transition: ${s("backgroundTransition")};
box-shadow: ${s("modalShadow")};
border-radius: 8px;
outline: none;
-1
View File
@@ -144,7 +144,6 @@ const EmojiButton = styled(NudeButton)<{
height: 28px;
padding: 6px;
border-radius: 12px;
transition: ${s("backgroundTransition")};
background: ${s("backgroundTertiary")};
pointer-events: ${({ disabled }) => disabled && "none"};
+16 -13
View File
@@ -2,7 +2,6 @@ import { ReactionIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import styled from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
@@ -11,6 +10,7 @@ import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
const EmojiPanel = React.lazy(
() => import("~/components/IconPicker/components/EmojiPanel")
@@ -98,15 +98,22 @@ const ReactionPicker: React.FC<Props> = ({
<>
<PopoverDisclosure {...popover}>
{(props) => (
<PopoverButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
size={size}
<Tooltip
content={t("Add reaction")}
placement="top"
delay={500}
hideOnClick
>
<ReactionIcon size={22} />
</PopoverButton>
<NudeButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
size={size}
>
<ReactionIcon size={22} />
</NudeButton>
</Tooltip>
)}
</PopoverDisclosure>
<Popover
@@ -151,8 +158,4 @@ const Placeholder = React.memo(
);
Placeholder.displayName = "ReactionPickerPlaceholder";
const PopoverButton = styled(NudeButton)`
border-radius: 50%;
`;
export default ReactionPicker;
+2 -2
View File
@@ -32,13 +32,13 @@ function Right({ children, border, className }: Props) {
Math.min(window.innerWidth - event.pageX, maxWidth),
minWidth
);
ui.setRightSidebarWidth(width);
ui.set({ sidebarRightWidth: width });
},
[minWidth, maxWidth, ui]
);
const handleReset = React.useCallback(() => {
ui.setRightSidebarWidth(theme.sidebarRightWidth);
ui.set({ sidebarRightWidth: theme.sidebarRightWidth });
}, [ui, theme.sidebarRightWidth]);
const handleStopDrag = React.useCallback(() => {
+14 -16
View File
@@ -46,7 +46,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const setWidth = ui.setSidebarWidth;
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
const [isAnimating, setAnimating] = React.useState(false);
@@ -62,13 +61,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const width = Math.min(event.pageX - offset, maxWidth);
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setWidth(theme.sidebarCollapsedWidth);
} else {
setWidth(width);
}
ui.set({
sidebarWidth: isSmallerThanCollapsePoint
? theme.sidebarCollapsedWidth
: width,
});
},
[theme, offset, minWidth, maxWidth, setWidth]
[ui, theme, offset, minWidth, maxWidth]
);
const handleStopDrag = React.useCallback(() => {
@@ -86,13 +85,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
setCollapsing(true);
ui.collapseSidebar();
} else {
setWidth(minWidth);
ui.set({ sidebarWidth: minWidth });
setAnimating(true);
}
} else {
setWidth(width);
ui.set({ sidebarWidth: width });
}
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
}, [ui, isSmallerThanMinimum, minWidth, width]);
const handleBlur = React.useCallback(() => {
setHovering(false);
@@ -149,11 +148,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
React.useEffect(() => {
if (isCollapsing) {
setTimeout(() => {
setWidth(minWidth);
ui.set({ sidebarWidth: minWidth });
setCollapsing(false);
}, ANIMATION_MS);
}
}, [setWidth, minWidth, isCollapsing]);
}, [ui, minWidth, isCollapsing]);
React.useEffect(() => {
if (isResizing) {
@@ -174,7 +173,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
}, [isResizing, handleDrag, handleBlur, handleStopDrag]);
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
ui.set({ sidebarWidth: theme.sidebarWidth });
}, [ui, theme.sidebarWidth]);
React.useEffect(() => {
@@ -299,9 +298,8 @@ const Container = styled(Flex)<ContainerProps>`
width: 100%;
background: ${s("sidebarBackground")};
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
${s("backgroundTransition")}
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
@@ -112,8 +112,9 @@ const NavLink = ({
!rest.target &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey,
[rest.target]
!event.ctrlKey &&
!isActive,
[rest.target, isActive]
);
const navigateTo = React.useCallback(() => {
@@ -153,14 +154,13 @@ const NavLink = ({
<Link
key={isActive ? "active" : "inactive"}
ref={linkRef}
// onMouseDown={handleClick}
onClick={handleClick}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
}}
onClick={handleClick}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -105,7 +105,6 @@ const Button = styled(Flex)<{
&:hover,
&[aria-expanded="true"] {
color: ${s("sidebarText")};
transition: background 100ms ease-in-out;
background: ${s("sidebarActiveBackground")};
}
@@ -78,7 +78,6 @@ function SidebarLink(
const activeStyle = React.useMemo(
() => ({
fontWeight: 600,
color: theme.text,
background: theme.sidebarActiveBackground,
...style,
@@ -202,10 +201,10 @@ const Link = styled(NavLink)<{
display: flex;
position: relative;
text-overflow: ellipsis;
font-weight: 475;
padding: 6px 16px;
border-radius: 4px;
min-height: 32px;
transition: background 50ms, color 50ms;
user-select: none;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
@@ -48,13 +48,20 @@ function StarredLink({ star }: Props) {
React.useEffect(() => {
if (
star.documentId === ui.activeDocumentId &&
sidebarContext === locationSidebarContext
) {
setExpanded(true);
} else if (
star.collectionId === ui.activeCollectionId &&
sidebarContext === locationSidebarContext
) {
setExpanded(true);
}
}, [
star.documentId,
star.collectionId,
ui.activeDocumentId,
ui.activeCollectionId,
sidebarContext,
locationSidebarContext,
+31
View File
@@ -0,0 +1,31 @@
import { ColumnSort } from "@tanstack/react-table";
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import useQuery from "~/hooks/useQuery";
import lazyWithRetry from "~/utils/lazyWithRetry";
import type { Props as TableProps } from "./Table";
const Table = lazyWithRetry(() => import("~/components/Table"));
export type Props<T> = Omit<TableProps<T>, "onChangeSort">;
export function SortableTable<T>(props: Props<T>) {
const location = useLocation();
const history = useHistory();
const params = useQuery();
const handleChangeSort = React.useCallback(
(sort: ColumnSort) => {
params.set("sort", sort.id);
params.set("direction", sort.desc ? "desc" : "asc");
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
return <Table onChangeSort={handleChangeSort} {...props} />;
}
-1
View File
@@ -31,7 +31,6 @@ const Background = styled.div<{ sticky?: boolean }>`
margin: 0 -8px;
padding: 0 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
z-index: 1;
`;
+303 -242
View File
@@ -1,231 +1,283 @@
import isEqual from "lodash/isEqual";
import {
useReactTable,
getCoreRowModel,
SortingState,
flexRender,
ColumnSort,
functionalUpdate,
Row as TRow,
createColumnHelper,
AccessorFn,
CellContext,
} from "@tanstack/react-table";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTable, useSortBy, usePagination } from "react-table";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import { s } from "@shared/styles";
import Button from "~/components/Button";
import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import usePrevious from "~/hooks/usePrevious";
export type Props = {
data: any[];
offset?: number;
isLoading: boolean;
empty?: React.ReactNode;
currentPage?: number;
page: number;
pageSize?: number;
totalPages?: number;
defaultSort?: string;
topRef?: React.Ref<any>;
onChangePage: (index: number) => void;
onChangeSort: (
sort: string | null | undefined,
direction: "ASC" | "DESC"
) => void;
columns: any;
defaultSortDirection: "ASC" | "DESC";
const HEADER_HEIGHT = 40;
type DataColumn<TData> = {
type: "data";
header: string;
accessor: AccessorFn<TData>;
sortable?: boolean;
};
function Table({
data,
isLoading,
totalPages,
empty,
columns,
page,
pageSize = 50,
defaultSort = "name",
topRef,
onChangeSort,
onChangePage,
defaultSortDirection,
}: Props) {
const { t } = useTranslation();
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
canNextPage,
nextPage,
canPreviousPage,
previousPage,
state: { pageIndex, sortBy },
} = useTable(
{
columns,
data,
manualPagination: true,
manualSortBy: true,
autoResetSortBy: false,
autoResetPage: false,
pageCount: totalPages,
initialState: {
sortBy: [
{
id: defaultSort,
desc: defaultSortDirection === "DESC" ? true : false,
},
],
pageSize,
pageIndex: page,
},
stateReducer: (newState, action, prevState) => {
if (!isEqual(newState.sortBy, prevState.sortBy)) {
return { ...newState, pageIndex: 0 };
}
type ActionColumn = {
type: "action";
header?: string;
};
return newState;
},
},
useSortBy,
usePagination
export type Column<TData> = {
id: string;
component: (data: TData) => React.ReactNode;
width: string;
} & (DataColumn<TData> | ActionColumn);
export type Props<TData> = {
data: TData[];
columns: Column<TData>[];
sort: ColumnSort;
onChangeSort: (sort: ColumnSort) => void;
loading: boolean;
page: {
hasNext: boolean;
fetchNext?: () => void;
};
rowHeight: number;
stickyOffset?: number;
};
function Table<TData>({
data,
columns,
sort,
onChangeSort,
loading,
page,
rowHeight,
stickyOffset = 0,
}: Props<TData>) {
const { t } = useTranslation();
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
const [virtualContainerTop, setVirtualContainerTop] =
React.useState<number>();
const columnHelper = React.useMemo(() => createColumnHelper<TData>(), []);
const observedColumns = React.useMemo(
() =>
columns.map((column) => {
const cell = ({ row }: CellContext<TData, unknown>) => (
<ObservedCell data={row.original} render={column.component} />
);
return column.type === "data"
? columnHelper.accessor(column.accessor, {
id: column.id,
header: column.header,
enableSorting: column.sortable ?? true,
cell,
})
: columnHelper.display({
id: column.id,
header: column.header ?? "",
cell,
});
}),
[columns, columnHelper]
);
const prevSortBy = React.useRef(sortBy);
const gridColumns = React.useMemo(
() => columns.map((column) => column.width).join(" "),
[columns]
);
const handleChangeSort = React.useCallback(
(sortState: SortingState) => {
const newState = functionalUpdate(sortState, [sort]);
const newSort = newState[0];
onChangeSort(newSort);
},
[sort, onChangeSort]
);
const prevSort = usePrevious(sort);
const sortChanged = sort !== prevSort;
const isEmpty = !loading && data.length === 0;
const showPlaceholder = loading && data.length === 0;
const table = useReactTable({
data,
columns: observedColumns,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
enableMultiSort: false,
enableSortingRemoval: false,
state: {
sorting: [sort],
},
onSortingChange: handleChangeSort,
});
const { rows } = table.getRowModel();
const rowVirtualizer = useWindowVirtualizer({
count: rows.length,
estimateSize: () => rowHeight,
scrollMargin: virtualContainerTop,
overscan: 5,
});
React.useEffect(() => {
if (!isEqual(sortBy, prevSortBy.current)) {
prevSortBy.current = sortBy;
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
if (!sortChanged || !virtualContainerTop) {
return;
}
const scrollThreshold =
virtualContainerTop - (stickyOffset + HEADER_HEIGHT);
const reset = window.scrollY > scrollThreshold;
if (reset) {
rowVirtualizer.scrollToOffset(scrollThreshold, {
behavior: "smooth",
});
}
}, [rowVirtualizer, sortChanged, virtualContainerTop, stickyOffset]);
React.useLayoutEffect(() => {
if (virtualContainerRef.current) {
// determine the scrollable virtual container offsetTop on mount
setVirtualContainerTop(
virtualContainerRef.current.getBoundingClientRect().top
);
}
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
const handleNextPage = () => {
nextPage();
onChangePage(pageIndex + 1);
};
const handlePreviousPage = () => {
previousPage();
onChangePage(pageIndex - 1);
};
const isEmpty = !isLoading && data.length === 0;
const showPlaceholder = isLoading && data.length === 0;
}, []);
return (
<div style={{ overflowX: "auto" }}>
<Anchor ref={topRef} />
<InnerTable {...getTableProps()}>
<thead>
{headerGroups.map((headerGroup) => {
const groupProps = headerGroup.getHeaderGroupProps();
return (
<tr {...groupProps} key={groupProps.key}>
{headerGroup.headers.map((column) => (
<Head
{...column.getHeaderProps(column.getSortByToggleProps())}
key={column.id}
<>
<InnerTable role="table">
<THead role="rowgroup" $topPos={stickyOffset}>
{table.getHeaderGroups().map((headerGroup) => (
<TR role="row" key={headerGroup.id} $columns={gridColumns}>
{headerGroup.headers.map((header) => (
<TH role="columnheader" key={header.id}>
<SortWrapper
align="center"
gap={4}
onClick={header.column.getToggleSortingHandler()}
$sortable={header.column.getCanSort()}
>
<SortWrapper
align="center"
$sortable={!column.disableSortBy}
gap={4}
>
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
<DescSortIcon />
) : (
<AscSortIcon />
))}
</SortWrapper>
</Head>
))}
</tr>
);
})}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row);
return (
<Row {...row.getRowProps()} key={row.id}>
{row.cells.map((cell) => (
<Cell
{...cell.getCellProps([
{
// @ts-expect-error ts-migrate(2339) FIXME: Property 'className' does not exist on type 'Colum... Remove this comment to see the full error message
className: cell.column.className,
},
])}
key={cell.column.id}
>
{cell.render("Cell")}
</Cell>
))}
</Row>
);
})}
</tbody>
{showPlaceholder && <Placeholder columns={columns.length} />}
</InnerTable>
{isEmpty ? (
empty || <Empty>{t("No results")}</Empty>
) : (
<Pagination
justify={canPreviousPage ? "space-between" : "flex-end"}
gap={8}
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getIsSorted() === "asc" ? (
<AscSortIcon />
) : header.column.getIsSorted() === "desc" ? (
<DescSortIcon />
) : (
<div />
)}
</SortWrapper>
</TH>
))}
</TR>
))}
</THead>
<TBody
ref={virtualContainerRef}
role="rowgroup"
$height={rowVirtualizer.getTotalSize()}
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={handlePreviousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={handleNextPage} neutral>
{t("Next page")}
</Button>
)}
</Pagination>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] as TRow<TData>;
return (
<TR
role="row"
key={row.id}
data-index={virtualRow.index}
style={{
position: "absolute",
transform: `translateY(${
virtualRow.start - rowVirtualizer.options.scrollMargin
}px)`,
height: `${virtualRow.size}px`,
}}
$columns={gridColumns}
>
{row.getAllCells().map((cell) => (
<TD role="cell" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TD>
))}
</TR>
);
})}
</TBody>
{showPlaceholder && (
<Placeholder columns={columns.length} gridColumns={gridColumns} />
)}
</InnerTable>
{page.hasNext && (
<Waypoint
key={data?.length}
onEnter={page.fetchNext}
bottomOffset={-rowHeight * 5}
/>
)}
</div>
{isEmpty && <Empty>{t("No results")}</Empty>}
</>
);
}
export const Placeholder = ({
const ObservedCell = observer(function <TData>({
data,
render,
}: {
data: TData;
render: (data: TData) => React.ReactNode;
}) {
return <>{render(data)}</>;
});
function Placeholder({
columns,
rows = 3,
gridColumns,
}: {
columns: number;
rows?: number;
}) => (
<DelayedMount>
<tbody>
{new Array(rows).fill(1).map((_, row) => (
<Row key={row}>
{new Array(columns).fill(1).map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
</DelayedMount>
);
const Anchor = styled.div`
top: -32px;
position: relative;
`;
const Pagination = styled(Flex)`
margin: 0 0 32px;
`;
gridColumns: string;
}) {
return (
<DelayedMount>
<TBody $height={150}>
{new Array(rows).fill(1).map((_r, row) => (
<TR key={row} $columns={gridColumns}>
{new Array(columns).fill(1).map((_c, col) => (
<TD key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</TD>
))}
</TR>
))}
</TBody>
</DelayedMount>
);
}
const DescSortIcon = styled(CollapsedIcon)`
margin-left: -2px;
@@ -239,12 +291,6 @@ const AscSortIcon = styled(DescSortIcon)`
transform: rotate(180deg);
`;
const InnerTable = styled.table`
border-collapse: collapse;
margin: 16px 0;
min-width: 100%;
`;
const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
display: inline-flex;
height: 24px;
@@ -253,6 +299,7 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
white-space: nowrap;
margin: 0 -4px;
padding: 0 4px;
cursor: ${(props) => (props.$sortable ? `var(--pointer)` : "")};
&:hover {
background: ${(props) =>
@@ -260,15 +307,66 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
}
`;
const Cell = styled.td`
padding: 10px 6px;
border-bottom: 1px solid ${s("divider")};
const InnerTable = styled.div`
width: 100%;
`;
const THead = styled.div<{ $topPos: number }>`
position: sticky;
top: ${({ $topPos }) => `${$topPos}px`};
height: ${HEADER_HEIGHT}px;
z-index: 1;
font-size: 14px;
text-wrap: nowrap;
color: ${s("textSecondary")};
font-weight: 500;
border-bottom: 1px solid ${s("divider")};
background: ${s("background")};
`;
const TBody = styled.div<{ $height: number }>`
position: relative;
height: ${({ $height }) => `${$height}px`};
`;
const TR = styled.div<{ $columns: string }>`
width: 100%;
display: grid;
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
&:last-child {
border-bottom: 0;
}
`;
const TH = styled.span`
padding: 6px 6px 2px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
`;
const TD = styled.span`
padding: 10px 6px;
font-size: 14px;
text-wrap: wrap;
word-break: break-word;
&:first-child {
font-size: 15px;
font-weight: 500;
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
&.actions,
@@ -291,41 +389,4 @@ const Cell = styled.td`
}
`;
const Row = styled.tr`
${Cell} {
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
&:last-child {
${Cell} {
border-bottom: 0;
}
}
`;
const Head = styled.th`
text-align: left;
padding: 6px 6px 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("background")};
transition: ${s("backgroundTransition")};
font-size: 14px;
color: ${s("textSecondary")};
font-weight: 500;
z-index: 1;
cursor: var(--pointer) !important;
:first-child {
padding-left: 0;
}
:last-child {
padding-right: 0;
}
`;
export default observer(Table);
-71
View File
@@ -1,71 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "scroll-into-view-if-needed";
import useQuery from "~/hooks/useQuery";
import lazyWithRetry from "~/utils/lazyWithRetry";
import type { Props } from "./Table";
const Table = lazyWithRetry(() => import("~/components/Table"));
const TableFromParams = (
props: Omit<Props, "onChangeSort" | "onChangePage" | "topRef">
) => {
const topRef = React.useRef();
const location = useLocation();
const history = useHistory();
const params = useQuery();
const handleChangeSort = React.useCallback(
(sort, direction) => {
if (sort) {
params.set("sort", sort);
} else {
params.delete("sort");
}
params.set("direction", direction.toLowerCase());
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangePage = React.useCallback(
(page) => {
if (page) {
params.set("page", page.toString());
} else {
params.delete("page");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
if (topRef.current) {
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "start",
});
}
},
[params, history, location.pathname]
);
return (
<Table
topRef={topRef}
onChangeSort={handleChangeSort}
onChangePage={handleChangePage}
{...props}
/>
);
};
export default observer(TableFromParams);
-1
View File
@@ -45,7 +45,6 @@ const Sticky = styled.div`
margin: 0 -8px;
padding: 0 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
z-index: 1;
`;
-42
View File
@@ -1,42 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
/** The size to render the indicator, defaults to 24px */
size?: number;
};
/**
* A component to show an animated typing indicator.
*/
export default function Typing({ size = 24 }: Props) {
return (
<Wrapper height={size} width={size}>
<Circle cx={size / 4} cy={size / 2} r="2" />
<Circle cx={size / 2} cy={size / 2} r="2" />
<Circle cx={size / 1.33333} cy={size / 2} r="2" />
</Wrapper>
);
}
const Wrapper = styled.svg`
fill: ${s("textTertiary")};
@keyframes blink {
50% {
fill: transparent;
}
}
`;
const Circle = styled.circle`
animation: 1s blink infinite;
&:nth-child(2) {
animation-delay: 250ms;
}
&:nth-child(3) {
animation-delay: 500ms;
}
`;
-7
View File
@@ -529,13 +529,6 @@ class WebsocketProvider extends React.Component<Props> {
stars.remove(event.modelId);
});
this.socket.on(
"user.typing",
(event: { userId: string; documentId: string; commentId: string }) => {
comments.setTyping(event);
}
);
this.socket.on("collections.add_user", async (event: Membership) => {
memberships.add(event);
await collections.fetch(event.collectionId, {
+6 -4
View File
@@ -131,13 +131,15 @@ function usePosition({
// Images need their own positioning to get the toolbar in the center
if (isImageSelection) {
const element = view.nodeDOM(selection.from) as HTMLElement;
const element = view.nodeDOM(selection.from);
// Images are wrapped which impacts positioning - need to get the element
// specifically tagged as the handle
const imageElement = element.getElementsByClassName(
EditorStyleHelper.imageHandle
)[0];
const imageElement = element
? (element as HTMLElement).getElementsByClassName(
EditorStyleHelper.imageHandle
)[0]
: undefined;
if (imageElement) {
const { left, top, width } = imageElement.getBoundingClientRect();
+25 -6
View File
@@ -92,6 +92,10 @@ export default class FindAndReplaceExtension extends Extension {
public replace(replace: string): Command {
return (state, dispatch) => {
// Redo the search to ensure we have the latest results, the document may
// have changed underneath us since the last search.
this.search(state.doc);
const result = this.results[this.currentResultIndex];
if (!result) {
@@ -106,7 +110,12 @@ export default class FindAndReplaceExtension extends Extension {
}
public replaceAll(replace: string): Command {
return ({ tr }, dispatch) => {
return (state, dispatch) => {
// Redo the search to ensure we have the latest results, the document may
// have changed underneath us since the last search.
this.search(state.doc);
const tr = state.tr;
let offset: number | undefined;
if (!this.results.length) {
@@ -248,15 +257,25 @@ export default class FindAndReplaceExtension extends Extension {
let m;
const search = this.findRegExp;
while ((m = search.exec(deburr(text)))) {
// We construct a string with the text stripped of diacritics plus the original text for
// search allowing to search for diacritics-insensitive matches easily.
while ((m = search.exec(deburr(text) + text))) {
if (m[0] === "") {
break;
}
this.results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
// Reconstruct the correct match position
const i = m.index >= text.length ? m.index - text.length : m.index;
const from = pos + i;
const to = from + m[0].length;
// Check if already exists in results, possible due to duplicated
// search string on L257
if (this.results.some((r) => r.from === from && r.to === to)) {
continue;
}
this.results.push({ from, to });
}
} catch (e) {
// Invalid RegExp
+7 -6
View File
@@ -1,5 +1,4 @@
import isEqual from "lodash/isEqual";
import { keymap } from "prosemirror-keymap";
import {
ySyncPlugin,
yCursorPlugin,
@@ -104,11 +103,13 @@ export default class Multiplayer extends Extension {
selectionBuilder,
}),
yUndoPlugin(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
];
}
commands() {
return {
undo: () => undo,
redo: () => redo,
};
}
}
-15
View File
@@ -4,7 +4,6 @@ import { darken, transparentize } from "polished";
import { baseKeymap } from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import { redo, undo } from "prosemirror-history";
import { inputRules, InputRule } from "prosemirror-inputrules";
import { keymap } from "prosemirror-keymap";
import { MarkdownParser } from "prosemirror-markdown";
@@ -608,20 +607,6 @@ export class Editor extends React.PureComponent<
this.props
);
/**
* Undo the last change in the editor.
*
* @returns True if the undo was successful
*/
public undo = () => undo(this.view.state, this.view.dispatch, this.view);
/**
* Redo the last change in the editor.
*
* @returns True if the change was successful
*/
public redo = () => redo(this.view.state, this.view.dispatch, this.view);
/**
* Returns true if the trimmed content of the editor is an empty string.
*
+1 -1
View File
@@ -31,7 +31,7 @@ export default function useAutoRefresh() {
return;
}
if (isReloaded) {
Logger.error("lifecycle", new Error("Attempted to reload twice"));
Logger.warn("Attempted to reload twice");
return;
}
+5 -4
View File
@@ -6,10 +6,7 @@ export default function useComponentSize(
width: number;
height: number;
} {
const [size, setSize] = useState({
width: ref.current?.clientWidth || 0,
height: ref.current?.clientHeight || 0,
});
const [size, setSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver((entries) => {
@@ -24,6 +21,10 @@ export default function useComponentSize(
});
if (ref.current) {
setSize({
width: ref.current?.clientWidth,
height: ref.current?.clientHeight,
});
sizeObserver.observe(ref.current);
}
+9 -1
View File
@@ -1,5 +1,13 @@
import React from "react";
import { useLocation } from "react-router-dom";
export default function useQuery() {
return new URLSearchParams(useLocation().search);
const location = useLocation();
const query = React.useMemo(
() => new URLSearchParams(location.search),
[location.search]
);
return query;
}
+102
View File
@@ -0,0 +1,102 @@
import sortBy from "lodash/sortBy";
import React from "react";
import {
FetchPageParams,
PaginatedResponse,
PAGINATION_SYMBOL,
} from "~/stores/base/Store";
import useRequest from "./useRequest";
const INITIAL_OFFSET = 0;
const PAGE_SIZE = 25;
type Props<T> = {
data: T[];
reqFn: (params: FetchPageParams) => Promise<PaginatedResponse<T>>;
reqParams: Omit<FetchPageParams, "offset" | "limit">;
};
type Response<T> = {
data: T[] | undefined;
error: unknown;
loading: boolean;
next: (() => void) | undefined;
};
export function useTableRequest<T extends { id: string }>({
data,
reqFn,
reqParams,
}: Props<T>): Response<T> {
const [dataIds, setDataIds] = React.useState<string[]>();
const [total, setTotal] = React.useState<number>();
const [offset, setOffset] = React.useState({ value: INITIAL_OFFSET });
const prevParamsRef = React.useRef(reqParams);
const fetchPage = React.useCallback(
() => reqFn({ ...reqParams, offset: offset.value, limit: PAGE_SIZE }),
[reqFn, reqParams, offset]
);
const { request, loading, error } = useRequest(fetchPage);
const nextPage = React.useCallback(
() =>
setOffset((prev) => ({
value: prev.value + PAGE_SIZE,
})),
[]
);
React.useEffect(() => {
if (prevParamsRef.current !== reqParams) {
prevParamsRef.current = reqParams;
setOffset({ value: INITIAL_OFFSET });
return;
}
let ignore = false;
const handleRequest = async () => {
const response = await request();
if (!response || ignore) {
return;
}
const ids = response.map((item) => item.id);
if (offset.value === INITIAL_OFFSET) {
setDataIds(response.map((item) => item.id));
} else {
setDataIds((prev) => (prev ?? []).concat(ids));
}
setTotal(response[PAGINATION_SYMBOL]?.total);
};
void handleRequest();
return () => {
ignore = true;
};
}, [reqParams, offset, request]);
const filteredData = dataIds
? sortBy(
data.filter((item) => dataIds.includes(item.id)),
(item) => dataIds.indexOf(item.id)
)
: undefined;
const next =
!loading && dataIds && total && dataIds.length < total
? nextPage
: undefined;
return {
data: filteredData,
error,
loading,
next,
};
}
-24
View File
@@ -1,8 +1,6 @@
import { subSeconds } from "date-fns";
import invariant from "invariant";
import uniq from "lodash/uniq";
import { action, computed, observable } from "mobx";
import { now } from "mobx-utils";
import { Pagination } from "@shared/constants";
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
import User from "~/models/User";
@@ -15,17 +13,6 @@ import Relation from "./decorators/Relation";
class Comment extends Model {
static modelName = "Comment";
/**
* Map to keep track of which users are currently typing a reply in this
* comments thread.
*/
@observable
typingUsers: Map<string, Date> = new Map();
@Field
@observable
id: string;
/**
* The Prosemirror data representing the comment content
*/
@@ -107,17 +94,6 @@ class Comment extends Model {
*/
private reactedUsersLoading = false;
/**
* An array of users that are currently typing a reply in this comments thread.
*/
@computed
public get currentlyTypingUsers(): User[] {
return Array.from(this.typingUsers.entries())
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
.map(([userId]) => this.store.rootStore.users.get(userId))
.filter(Boolean) as User[];
}
/**
* Whether the comment is resolved
*/
+2 -4
View File
@@ -65,10 +65,6 @@ export default class Document extends ArchivableModel {
store: DocumentsStore;
@Field
@observable
id: string;
@observable.shallow
data: ProsemirrorData;
@@ -577,6 +573,8 @@ export default class Document extends ArchivableModel {
title?: string;
publish?: boolean;
recursive?: boolean;
collectionId?: string | null;
parentDocumentId?: string;
}) => this.store.duplicate(this, options);
/**
-4
View File
@@ -6,10 +6,6 @@ import Field from "./decorators/Field";
class Group extends Model {
static modelName = "Group";
@Field
@observable
id: string;
@Field
@observable
name: string;
-4
View File
@@ -18,10 +18,6 @@ import Relation from "./decorators/Relation";
class Notification extends Model {
static modelName = "Notification";
@Field
@observable
id: string;
/**
* The date the notification was marked as read.
*/
+3
View File
@@ -59,6 +59,9 @@ class Share extends Model {
@observable
allowIndexing: boolean;
@observable
views: number;
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
-4
View File
@@ -8,10 +8,6 @@ import Field from "./decorators/Field";
class Team extends Model {
static modelName = "Team";
@Field
@observable
id: string;
@Field
@observable
name: string;
-4
View File
@@ -22,10 +22,6 @@ import Field from "./decorators/Field";
class User extends ParanoidModel {
static modelName = "User";
@Field
@observable
id: string;
@Field
@observable
avatarUrl: string;
-4
View File
@@ -5,10 +5,6 @@ import Field from "./decorators/Field";
class WebhookSubscription extends Model {
static modelName = "WebhookSubscription";
@Field
@observable
id: string;
@Field
@observable
name: string;
@@ -49,8 +49,6 @@ type Props = {
highlightedText?: string;
/** The text direction of the editor */
dir?: "rtl" | "ltr";
/** Callback when the user is typing in the editor */
onTyping?: () => void;
/** Callback when the editor is focused */
onFocus?: () => void;
/** Callback when the editor is blurred */
@@ -63,7 +61,6 @@ function CommentForm({
draft,
onSubmit,
onSaveDraft,
onTyping,
onFocus,
onBlur,
autoFocus,
@@ -182,7 +179,6 @@ function CommentForm({
) => {
const text = value(true, true);
onSaveDraft(text ? value(false, true) : undefined);
onTyping?.();
};
const handleSave = () => {
@@ -1,5 +1,5 @@
import throttle from "lodash/throttle";
import { observer } from "mobx-react";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
@@ -10,14 +10,11 @@ import { s } from "@shared/styles";
import { ProsemirrorData } from "@shared/types";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { Avatar } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Typing from "~/components/Typing";
import { WebsocketContext } from "~/components/WebsocketProvider";
import useCurrentUser from "~/hooks/useCurrentUser";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -40,28 +37,12 @@ type Props = {
enableScroll: () => void;
/** Disable scroll for the comments container */
disableScroll: () => void;
/** Number of replies before collapsing */
collapseThreshold?: number;
/** Number of replies to display when collapsed */
collapseNumDisplayed?: number;
};
function useTypingIndicator({
document,
comment,
}: Pick<Props, "document" | "comment">): [undefined, () => void] {
const socket = React.useContext(WebsocketContext);
const setIsTyping = React.useMemo(
() =>
throttle(() => {
socket?.emit("typing", {
documentId: document.id,
commentId: comment.id,
});
}, 500),
[socket, document.id, comment.id]
);
return [undefined, setIsTyping];
}
function CommentThread({
comment: thread,
document,
@@ -69,21 +50,19 @@ function CommentThread({
focused,
enableScroll,
disableScroll,
collapseThreshold = 5,
collapseNumDisplayed = 3,
}: Props) {
const [focusedOnMount] = React.useState(focused);
const { editor } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
const replyRef = React.useRef<HTMLDivElement>(null);
const user = useCurrentUser();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
const [, setIsTyping] = useTypingIndicator({
document,
comment: thread,
});
const can = usePolicy(document);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
@@ -102,6 +81,17 @@ function CommentThread({
.inThread(thread.id)
.filter((comment) => !comment.isNew);
const [collapse, setCollapse] = React.useState(() => {
const numReplies = commentsInThread.length - 1;
if (numReplies >= collapseThreshold) {
return {
begin: 1,
final: commentsInThread.length - collapseNumDisplayed - 1,
};
}
return null;
});
useOnClickOutside(topRef, (event) => {
if (
focused &&
@@ -129,6 +119,36 @@ function CommentThread({
});
};
const handleClickExpand = (ev: React.SyntheticEvent) => {
ev.stopPropagation();
setCollapse(null);
};
const renderShowMore = (collapse: { begin: number; final: number }) => {
const count = collapse.final - collapse.begin + 1;
const createdBy = commentsInThread
.slice(collapse.begin, collapse.final + 1)
.map((c) => c.createdBy);
const users = Array.from(new Set(createdBy));
const limit = 3;
const overflow = users.length - limit;
return (
<ShowMore onClick={handleClickExpand} key="show-more">
{t("Show {{ count }} reply", { count })}
<Facepile
users={users}
limit={limit}
overflow={overflow}
size={AvatarSize.Medium}
renderAvatar={(item) => (
<Avatar size={AvatarSize.Medium} model={item} />
)}
/>
</ShowMore>
);
};
React.useEffect(() => {
if (!focused && autoFocus) {
setAutoFocus(false);
@@ -192,8 +212,17 @@ function CommentThread({
onClick={handleClickThread}
>
{commentsInThread.map((comment, index) => {
if (collapse !== null) {
if (index === collapse.begin) {
return renderShowMore(collapse);
} else if (index > collapse.begin && index <= collapse.final) {
return null;
}
}
const firstOfAuthor =
index === 0 ||
(collapse && index === collapse.final + 1) ||
comment.createdById !== commentsInThread[index - 1].createdById;
const lastOfAuthor =
index === commentsInThread.length - 1 ||
@@ -219,15 +248,6 @@ function CommentThread({
);
})}
{thread.currentlyTypingUsers
.filter((typing) => typing.id !== user.id)
.map((typing) => (
<Flex gap={8} key={typing.id}>
<Avatar model={typing} size={24} />
<Typing />
</Flex>
))}
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
{(focused || draft || commentsInThread.length === 0) && canReply && (
<Fade timing={100}>
@@ -237,7 +257,6 @@ function CommentThread({
draft={draft}
documentId={document.id}
thread={thread}
onTyping={setIsTyping}
standalone={commentsInThread.length === 0}
dir={document.dir}
autoFocus={autoFocus}
@@ -276,6 +295,29 @@ const Reply = styled.button`
`}
`;
const ShowMore = styled.div<{ $dir?: "rtl" | "ltr" }>`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1px;
margin-left: ${(props) => (props.$dir === "rtl" ? 0 : 32)}px;
margin-right: ${(props) => (props.$dir !== "rtl" ? 0 : 32)}px;
padding: 8px 12px;
color: ${s("textTertiary")};
background: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
cursor: var(--pointer);
font-size: 13px;
&: ${hover} {
color: ${s("textSecondary")};
background: ${s("backgroundTertiary")};
}
* {
border-color: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
}
`;
const Thread = styled.div<{
$focused: boolean;
$recessed: boolean;
@@ -1,6 +1,7 @@
import { differenceInMilliseconds } from "date-fns";
import { action } from "mobx";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -16,10 +17,14 @@ import Comment from "~/models/Comment";
import { Avatar } from "~/components/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import ReactionList from "~/components/Reactions/ReactionList";
import ReactionPicker from "~/components/Reactions/ReactionPicker";
import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import { resolveCommentFactory } from "~/actions/definitions/comments";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
@@ -242,11 +247,13 @@ function CommentThreadItem({
onRemoveReaction={handleRemoveReaction}
picker={
!comment.isResolved ? (
<StyledReactionPicker
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
size={28}
rounded
/>
) : undefined
}
@@ -257,14 +264,20 @@ function CommentThreadItem({
<EventBoundary>
{!isEditing && (
<Actions gap={4} dir={dir}>
{firstOfThread && (
<ResolveButton onUpdate={handleUpdate} comment={comment} />
)}
{!comment.isResolved && (
<StyledReactionPicker
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
rounded
/>
)}
<StyledMenu
<Action
as={CommentMenu}
comment={comment}
onEdit={setEditing}
onDelete={handleDelete}
@@ -278,6 +291,38 @@ function CommentThreadItem({
);
}
const ResolveButton = ({
comment,
onUpdate,
}: {
comment: Comment;
onUpdate: (attrs: { resolved: boolean }) => void;
}) => {
const context = useActionContext();
const { t } = useTranslation();
return (
<Tooltip
content={t("Mark as resolved")}
placement="top"
delay={500}
hideOnClick
>
<Action
as={NudeButton}
context={context}
action={resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
})}
rounded
>
<DoneIcon size={22} outline />
</Action>
</Tooltip>
);
};
const StyledCommentEditor = styled(CommentEditor)`
${(props) =>
!props.readOnly &&
@@ -308,25 +353,13 @@ const Body = styled.form`
border-radius: 2px;
`;
const StyledMenu = styled(CommentMenu)`
color: ${s("textSecondary")};
svg {
fill: currentColor;
opacity: 0.5;
}
&: ${hover}, &[aria-expanded= "true"] {
background: ${s("backgroundQuaternary")};
svg {
opacity: 0.75;
}
}
`;
const StyledReactionPicker = styled(ReactionPicker)`
const Action = styled.span<{ rounded?: boolean }>`
color: ${s("textSecondary")};
${(props) =>
props.rounded &&
css`
border-radius: 50%;
`}
svg {
fill: currentColor;
@@ -352,7 +385,7 @@ const Actions = styled(Flex)<{ dir?: "rtl" | "ltr" }>`
background: ${s("backgroundSecondary")};
padding-left: 4px;
&:has(${StyledReactionPicker}[aria-expanded="true"], ${StyledMenu}[aria-expanded="true"]) {
&:has(${Action}[aria-expanded="true"]) {
opacity: 1;
}
`;
@@ -386,7 +419,7 @@ export const Bubble = styled(Flex)<{
min-width: 2em;
margin-bottom: 1px;
padding: 8px 12px;
transition: color 100ms ease-out, ${s("backgroundTransition")};
transition: color 100ms ease-out, background 100ms ease-out;
${({ $lastOfThread, $canReply }) =>
$lastOfThread &&
+2 -2
View File
@@ -44,7 +44,7 @@ function Comments() {
const isAtBottom = React.useRef(true);
const [showJumpToRecentBtn, setShowJumpToRecentBtn] = React.useState(false);
useKeyDown("Escape", () => document && ui.collapseComments(document?.id));
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document?.id}-new`,
@@ -126,7 +126,7 @@ function Comments() {
<CommentSortMenu />
</Flex>
}
onClose={() => ui.collapseComments(document?.id)}
onClose={() => ui.set({ commentsExpanded: false })}
scrollable={false}
>
<Scrollable
@@ -87,7 +87,6 @@ const StickyWrapper = styled.div`
border-radius: 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
+8 -6
View File
@@ -215,13 +215,15 @@ class DocumentScene extends React.Component<Props> {
onUndoRedo = (event: KeyboardEvent) => {
if (isModKey(event)) {
event.preventDefault();
if (event.shiftKey) {
if (this.editor.current?.redo()) {
event.preventDefault();
if (!this.props.readOnly) {
this.editor.current?.commands.redo();
}
} else {
if (this.editor.current?.undo()) {
event.preventDefault();
if (!this.props.readOnly) {
this.editor.current?.commands.undo();
}
}
}
@@ -410,7 +412,8 @@ class DocumentScene extends React.Component<Props> {
(team && team.documentEmbeds === false) || document.embedsDisabled;
const showContents =
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
const tocPos =
tocPosition ??
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
@@ -695,7 +698,6 @@ const Footer = styled.div`
const Background = styled(Container)`
position: relative;
background: ${s("background")};
transition: ${s("backgroundTransition")};
`;
const ReferencesWrapper = styled.div`
@@ -46,7 +46,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
&nbsp;&nbsp;
<CommentLink
to={documentPath(document)}
onClick={() => ui.toggleComments(document.id)}
onClick={() => ui.toggleComments()}
>
<CommentIcon size={18} />
{commentsCount
+1 -1
View File
@@ -116,7 +116,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
state: { commentId: focusedComment.id },
});
}
ui.expandComments(document.id);
ui.set({ commentsExpanded: true });
}
}, [focusedComment, ui, document.id, history, params]);
+13 -4
View File
@@ -103,6 +103,10 @@ function DocumentHeader({
});
}, [onSave]);
const handleToggle = React.useCallback(() => {
ui.set({ tocVisible: !ui.tocVisible });
}, [ui]);
const context = useActionContext({
activeDocumentId: document?.id,
});
@@ -113,7 +117,8 @@ function DocumentHeader({
const canToggleEmbeds = team?.documentEmbeds;
const isShare = !!shareId;
const showContents =
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
const toc = (
<Tooltip
@@ -129,7 +134,7 @@ function DocumentHeader({
placement="bottom"
>
<Button
onClick={showContents ? ui.hideTableOfContents : ui.showTableOfContents}
onClick={handleToggle}
icon={<TableOfContentsIcon />}
borderOnHover
neutral
@@ -180,7 +185,7 @@ function DocumentHeader({
useKeyDown(
(event) => event.ctrlKey && event.altKey && event.key === "˙",
ui.tocVisible ? ui.hideTableOfContents : ui.showTableOfContents,
handleToggle,
{
allowInInput: true,
}
@@ -232,7 +237,11 @@ function DocumentHeader({
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document}>
{toc} <Star document={document} color={theme.textSecondary} />
{document.isTemplate ? null : (
<>
{toc} <Star document={document} color={theme.textSecondary} />
</>
)}
</DocumentBreadcrumb>
)
}
@@ -273,6 +273,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
<>
{showCache && (
<Editor
editorStyle={props.editorStyle}
embedsDisabled={props.embedsDisabled}
defaultValue={props.defaultValue}
extensions={props.extensions}
@@ -290,8 +291,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
style={
showCache
? {
height: 0,
opacity: 0,
pointerEvents: "none",
}
: undefined
}
@@ -31,11 +31,13 @@ function References({ document }: Props) {
: [];
const showBacklinks = !!backlinks.length;
const showChildDocuments = !!children.length;
const shouldFade = React.useRef(!showBacklinks && !showChildDocuments);
const isBacklinksTab = location.hash === "#backlinks" || !showChildDocuments;
const height = Math.max(backlinks.length, children.length) * 40;
const Component = shouldFade.current ? Fade : React.Fragment;
return showBacklinks || showChildDocuments ? (
<Fade>
<Component>
<Tabs>
{showChildDocuments && (
<Tab to="#children" isActive={() => !isBacklinksTab}>
@@ -80,7 +82,7 @@ function References({ document }: Props) {
</List>
)}
</Content>
</Fade>
</Component>
) : null;
}
+15 -3
View File
@@ -28,12 +28,24 @@ function DocumentMove({ document }: Props) {
);
const items = React.useMemo(() => {
// Recursively filter out the document itself and its existing parent doc, if any.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
.map(filterSourceDocument),
});
// Filter out the document itself and its existing parent doc, if any.
const nodes = flatten(collectionTrees.map(flattenTree))
.filter(
(node) =>
node.id !== document.id && node.id !== document.parentDocumentId
)
.map(filterSourceDocument)
// Filter out collections that we don't have permission to create documents in.
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
@@ -108,21 +120,21 @@ function DocumentMove({ document }: Props) {
);
}
const FlexContainer = styled(Flex)`
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
const Footer = styled(Flex)`
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
const StyledText = styled(Text)`
export const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
-1
View File
@@ -118,7 +118,6 @@ function Home() {
const Documents = styled.div`
position: relative;
background: ${s("background")};
transition: ${s("backgroundTransition")};
`;
export default observer(Home);
+1 -1
View File
@@ -127,7 +127,7 @@ function Invite({ onSubmit }: Props) {
<Trans>{{ collectionCount }} collections</Trans>
</strong>
</Tooltip>
.
.{" "}
</span>
) : undefined;
+2 -1
View File
@@ -231,7 +231,8 @@ function Login({ children }: Props) {
config.providers.length === 1 &&
config.providers[0].id === "oidc" &&
!env.OIDC_DISABLE_REDIRECT &&
!query.get("notice")
!query.get("notice") &&
!query.get("logout")
) {
window.location.href = getRedirectUrl(config.providers[0].authUrl);
return null;
+2 -1
View File
@@ -2,6 +2,7 @@ import * as React from "react";
import { Redirect } from "react-router-dom";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { logoutPath } from "~/utils/routeHelpers";
const Logout = () => {
const { auth } = useStores();
@@ -17,7 +18,7 @@ const Logout = () => {
if (env.OIDC_LOGOUT_URI) {
return null; // user will be redirected to logout URI after logout
}
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
export default Logout;
@@ -59,7 +59,6 @@ const StyledInput = styled.input`
outline: none;
border: 0;
background: ${s("sidebarBackground")};
transition: ${s("backgroundTransition")};
border-radius: 4px;
color: ${s("text")};
+118 -121
View File
@@ -1,15 +1,18 @@
import sortBy from "lodash/sortBy";
import { ColumnSort } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { PlusIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import styled from "styled-components";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import User from "~/models/User";
import { depths, s } from "@shared/styles";
import UsersStore from "~/stores/UsersStore";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import { HEADER_HEIGHT } from "~/components/Header";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
@@ -21,11 +24,13 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import PeopleTable from "./components/PeopleTable";
import { useTableRequest } from "~/hooks/useTableRequest";
import { PeopleTable } from "./components/PeopleTable";
import UserRoleFilter from "./components/UserRoleFilter";
import UserStatusFilter from "./components/UserStatusFilter";
function Members() {
const appName = env.APP_NAME;
const location = useLocation();
const history = useHistory();
const team = useCurrentTeam();
@@ -33,83 +38,46 @@ function Members() {
const { users } = useStores();
const { t } = useTranslation();
const params = useQuery();
const [isLoading, setIsLoading] = React.useState(false);
const [data, setData] = React.useState<User[]>([]);
const [totalPages, setTotalPages] = React.useState(0);
const [userIds, setUserIds] = React.useState<string[]>([]);
const can = usePolicy(team);
const query = params.get("query") || undefined;
const filter = params.get("filter") || undefined;
const role = params.get("role") || undefined;
const sort = params.get("sort") || "name";
const direction = (params.get("direction") || "asc").toUpperCase() as
| "ASC"
| "DESC";
const page = parseInt(params.get("page") || "0", 10);
const limit = 25;
const [query, setQuery] = React.useState("");
React.useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
const reqParams = React.useMemo(
() => ({
query: params.get("query") || undefined,
filter: params.get("filter") || undefined,
role: params.get("role") || undefined,
sort: params.get("sort") || "name",
direction: (params.get("direction") || "asc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
try {
const response = await users.fetchPage({
offset: page * limit,
limit,
sort,
direction,
query,
filter,
role,
});
if (response[PAGINATION_SYMBOL]) {
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
}
setUserIds(response.map((u: User) => u.id));
} finally {
setIsLoading(false);
}
};
const sort: ColumnSort = React.useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
void fetchData();
}, [query, sort, filter, role, page, direction, users]);
const { data, error, loading, next } = useTableRequest({
data: getFilteredUsers({
users,
filter: reqParams.filter,
role: reqParams.role,
}),
reqFn: users.fetchPage,
reqParams,
});
React.useEffect(() => {
let filtered = users.orderedData;
if (!filter) {
filtered = users.active.filter((u) => userIds.includes(u.id));
} else if (filter === "all") {
filtered = users.orderedData.filter((u) => userIds.includes(u.id));
} else if (filter === "suspended") {
filtered = users.suspended.filter((u) => userIds.includes(u.id));
} else if (filter === "invited") {
filtered = users.invited.filter((u) => userIds.includes(u.id));
}
if (role) {
filtered = filtered.filter((u) => u.role === role);
}
// sort the resulting data by the original order from the server
setData(sortBy(filtered, (item) => userIds.indexOf(item.id)));
}, [
filter,
role,
users.active,
users.orderedData,
users.suspended,
users.invited,
userIds,
]);
const handleStatusFilter = React.useCallback(
(f) => {
if (f) {
params.set("filter", f);
params.delete("page");
const updateParams = React.useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete("filter");
params.delete(name);
}
history.replace({
@@ -120,43 +88,31 @@ function Members() {
[params, history, location.pathname]
);
const handleStatusFilter = React.useCallback(
(status) => updateParams("filter", status),
[updateParams]
);
const handleRoleFilter = React.useCallback(
(r) => {
if (r) {
params.set("role", r);
params.delete("page");
} else {
params.delete("role");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
(role) => updateParams("role", role),
[updateParams]
);
const handleSearch = React.useCallback(
(event) => {
const { value } = event.target;
const handleSearch = React.useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
if (value) {
params.set("query", event.target.value);
params.delete("page");
} else {
params.delete("query");
}
React.useEffect(() => {
if (error) {
toast.error(t("Could not load members"));
}
}, [t, error]);
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const appName = env.APP_NAME;
React.useEffect(() => {
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateParams]);
return (
<Scene
@@ -191,35 +147,76 @@ function Members() {
{{ signinMethods: team.signinMethods }} but havent signed in yet.
</Trans>
</Text>
<Flex gap={8}>
<StickyFilters gap={8}>
<InputSearch
short
value={query ?? ""}
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={filter ?? ""}
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={role ?? ""}
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
</Flex>
<PeopleTable
data={data}
canManage={can.update}
isLoading={isLoading}
page={page}
pageSize={limit}
totalPages={totalPages}
defaultSortDirection="ASC"
/>
</StickyFilters>
<Fade>
<PeopleTable
data={data ?? []}
sort={sort}
canManage={can.update}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</Fade>
</Scene>
);
}
function getFilteredUsers({
users,
filter,
role,
}: {
users: UsersStore;
filter?: string;
role?: string;
}) {
let filteredUsers;
switch (filter) {
case "all":
filteredUsers = users.orderedData;
break;
case "suspended":
filteredUsers = users.suspended;
break;
case "invited":
filteredUsers = users.invited;
break;
default:
filteredUsers = users.active;
}
return role
? filteredUsers.filter((user) => user.role === role)
: filteredUsers;
}
const StickyFilters = styled(Flex)`
height: 40px;
position: sticky;
top: ${HEADER_HEIGHT}px;
z-index: ${depths.header};
background: ${s("background")};
`;
const LargeUserStatusFilter = styled(UserStatusFilter)`
height: 32px;
`;
+2 -3
View File
@@ -220,9 +220,6 @@ function Security() {
</SettingRow>
)}
{!data.inviteRequired && (
<DomainManagement onSuccess={showSuccessMessage} />
)}
{!data.inviteRequired && (
<SettingRow
label={t("Default role")}
@@ -252,6 +249,8 @@ function Security() {
</SettingRow>
)}
<DomainManagement onSuccess={showSuccessMessage} />
<h2>{t("Behavior")}</h2>
<SettingRow
label={t("Public document sharing")}
+40 -53
View File
@@ -1,11 +1,10 @@
import sortBy from "lodash/sortBy";
import { ColumnSort } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { GlobeIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import Share from "~/models/Share";
import { toast } from "sonner";
import Fade from "~/components/Fade";
import Heading from "~/components/Heading";
import Notice from "~/components/Notice";
@@ -15,7 +14,8 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import SharesTable from "./components/SharesTable";
import { useTableRequest } from "~/hooks/useTableRequest";
import { SharesTable } from "./components/SharesTable";
function Shares() {
const team = useCurrentTeam();
@@ -23,51 +23,37 @@ function Shares() {
const { shares, auth } = useStores();
const canShareDocuments = auth.team && auth.team.sharing;
const can = usePolicy(team);
const [isLoading, setIsLoading] = React.useState(false);
const [data, setData] = React.useState<Share[]>([]);
const [totalPages, setTotalPages] = React.useState(0);
const [shareIds, setShareIds] = React.useState<string[]>([]);
const params = useQuery();
const query = params.get("query") || "";
const sort = params.get("sort") || "createdAt";
const direction = (params.get("direction") || "desc").toUpperCase() as
| "ASC"
| "DESC";
const page = parseInt(params.get("page") || "0", 10);
const limit = 25;
const reqParams = React.useMemo(
() => ({
sort: params.get("sort") || "createdAt",
direction: (params.get("direction") || "desc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
const sort: ColumnSort = React.useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const { data, error, loading, next } = useTableRequest({
data: shares.orderedData,
reqFn: shares.fetchPage,
reqParams,
});
React.useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await shares.fetchPage({
offset: page * limit,
limit,
sort,
direction,
});
if (response[PAGINATION_SYMBOL]) {
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
}
setShareIds(response.map((u: Share) => u.id));
} finally {
setIsLoading(false);
}
};
void fetchData();
}, [query, sort, page, direction, shares]);
React.useEffect(() => {
// sort the resulting data by the original order from the server
setData(
sortBy(
shares.orderedData.filter((item) => shareIds.includes(item.id)),
(item) => shareIds.indexOf(item.id)
)
);
}, [shares.orderedData, shareIds]);
if (error) {
toast.error(t("Could not load shares"));
}
}, [t, error]);
return (
<Scene title={t("Shared Links")} icon={<GlobeIcon />} wide>
@@ -96,16 +82,17 @@ function Shares() {
</Trans>
</Text>
{data.length ? (
{data?.length ? (
<Fade>
<SharesTable
data={data}
data={data ?? []}
sort={sort}
canManage={can.update}
isLoading={isLoading}
page={page}
pageSize={limit}
totalPages={totalPages}
defaultSortDirection="ASC"
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</Fade>
) : null}
@@ -12,7 +12,6 @@ export const ActionRow = styled.div`
margin: 0 -50vw;
background: ${s("background")};
transition: ${s("backgroundTransition")};
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
@@ -5,7 +5,6 @@ import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import NudeButton from "~/components/NudeButton";
@@ -110,29 +109,25 @@ function DomainManagement({ onSuccess }: Props) {
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
{!allowedDomains.length ||
allowedDomains[allowedDomains.length - 1] !== "" ? (
<Fade>
<Button type="button" onClick={handleAddDomain} neutral>
{allowedDomains.length ? (
<Trans>Add another</Trans>
) : (
<Trans>Add a domain</Trans>
)}
</Button>
</Fade>
<Button type="button" onClick={handleAddDomain} neutral>
{allowedDomains.length ? (
<Trans>Add another</Trans>
) : (
<Trans>Add a domain</Trans>
)}
</Button>
) : (
<span />
)}
{showSaveChanges && (
<Fade>
<Button
type="button"
onClick={handleSaveDomains}
disabled={team.isSaving}
>
<Trans>Save changes</Trans>
</Button>
</Fade>
<Button
type="button"
onClick={handleSaveDomains}
disabled={team.isSaving}
>
<Trans>Save changes</Trans>
</Button>
)}
</Flex>
</SettingRow>
+67 -51
View File
@@ -1,4 +1,4 @@
import { observer } from "mobx-react";
import compact from "lodash/compact";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -6,94 +6,110 @@ import User from "~/models/User";
import { Avatar } from "~/components/Avatar";
import Badge from "~/components/Badge";
import Flex from "~/components/Flex";
import TableFromParams from "~/components/TableFromParams";
import { HEADER_HEIGHT } from "~/components/Header";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import UserMenu from "~/menus/UserMenu";
type Props = Omit<React.ComponentProps<typeof TableFromParams>, "columns"> & {
data: User[];
const ROW_HEIGHT = 60;
const STICKY_OFFSET = HEADER_HEIGHT + 40; // filter height
type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function PeopleTable({ canManage, ...rest }: Props) {
export function PeopleTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const columns = React.useMemo(
const columns = React.useMemo<TableColumn<User>[]>(
() =>
[
compact<TableColumn<User>>([
{
type: "data",
id: "name",
Header: t("Name"),
accessor: "name",
Cell: observer(
({ value, row }: { value: string; row: { original: User } }) => (
<Flex align="center" gap={8}>
<Avatar model={row.original} size={32} /> {value}{" "}
{currentUser.id === row.original.id && `(${t("You")})`}
</Flex>
)
header: t("Name"),
accessor: (user) => user.name,
component: (user) => (
<Flex align="center" gap={8}>
<Avatar model={user} size={32} /> {user.name}{" "}
{currentUser.id === user.id && `(${t("You")})`}
</Flex>
),
width: "4fr",
},
canManage
? {
type: "data",
id: "email",
Header: t("Email"),
accessor: "email",
Cell: observer(({ value }: { value: string }) => <>{value}</>),
header: t("Email"),
accessor: (user) => user.email,
component: (user) => <>{user.email}</>,
width: "4fr",
}
: undefined,
{
type: "data",
id: "lastActiveAt",
Header: t("Last active"),
accessor: "lastActiveAt",
Cell: observer(({ value }: { value: string }) =>
value ? <Time dateTime={value} addSuffix /> : null
),
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
id: "isAdmin",
Header: t("Role"),
accessor: "rank",
Cell: observer(({ row }: { row: { original: User } }) => (
<Badges>
{!row.original.lastActiveAt && <Badge>{t("Invited")}</Badge>}
{row.original.isAdmin ? (
type: "data",
id: "role",
header: t("Role"),
accessor: (user) => user.role,
component: (user) => (
<Badges wrap>
{!user.lastActiveAt && <Badge>{t("Invited")}</Badge>}
{user.isAdmin ? (
<Badge primary>{t("Admin")}</Badge>
) : row.original.isViewer ? (
) : user.isViewer ? (
<Badge>{t("Viewer")}</Badge>
) : row.original.isGuest ? (
) : user.isGuest ? (
<Badge yellow>{t("Guest")}</Badge>
) : (
<Badge>{t("Editor")}</Badge>
)}
{row.original.isSuspended && <Badge>{t("Suspended")}</Badge>}
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
)),
),
width: "2fr",
},
canManage
? {
Header: " ",
accessor: "id",
className: "actions",
disableSortBy: true,
Cell: observer(
({ row, value }: { value: string; row: { original: User } }) =>
currentUser.id !== value ? (
<UserMenu user={row.original} />
) : null
),
type: "action",
id: "action",
component: (user) =>
currentUser.id !== user.id ? <UserMenu user={user} /> : null,
width: "50px",
}
: undefined,
].filter((i) => i),
[t, canManage, currentUser]
]),
[t, currentUser, canManage]
);
return <TableFromParams columns={columns} {...rest} />;
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
{...rest}
/>
);
}
const Badges = styled.div`
const Badges = styled(Flex)`
margin-left: -10px;
row-gap: 4px;
`;
export default observer(PeopleTable);
+83 -59
View File
@@ -1,108 +1,132 @@
import { observer } from "mobx-react";
import compact from "lodash/compact";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Share from "~/models/Share";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import TableFromParams from "~/components/TableFromParams";
import { HEADER_HEIGHT } from "~/components/Header";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useUserLocale from "~/hooks/useUserLocale";
import ShareMenu from "~/menus/ShareMenu";
import { formatNumber } from "~/utils/language";
type Props = Omit<React.ComponentProps<typeof TableFromParams>, "columns"> & {
data: Share[];
const ROW_HEIGHT = 50;
type Props = Omit<TableProps<Share>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function SharesTable({ canManage, data, ...rest }: Props) {
export function SharesTable({ data, canManage, ...rest }: Props) {
const { t } = useTranslation();
const language = useUserLocale();
const hasDomain = data.some((share) => share.domain);
const columns = React.useMemo(
const columns = React.useMemo<TableColumn<Share>[]>(
() =>
[
compact<TableColumn<Share>>([
{
id: "documentTitle",
Header: t("Document"),
accessor: "documentTitle",
disableSortBy: true,
Cell: observer(({ value }: { value: string }) => <>{value}</>),
type: "data",
id: "title",
header: t("Document"),
accessor: (share) => share.documentTitle,
sortable: false,
component: (share) => <>{share.documentTitle}</>,
width: "4fr",
},
{
id: "who",
Header: t("Shared by"),
accessor: "createdById",
disableSortBy: true,
Cell: observer(
({ row }: { value: string; row: { original: Share } }) => (
<Flex align="center" gap={4}>
{row.original.createdBy && (
<Avatar model={row.original.createdBy} />
)}
{row.original.createdBy.name}
</Flex>
)
type: "data",
id: "createdBy",
header: t("Shared by"),
accessor: (share) => share.createdBy,
sortable: false,
component: (share) => (
<Flex align="center" gap={4}>
{share.createdBy && (
<>
<Avatar model={share.createdBy} />
{share.createdBy.name}
</>
)}
</Flex>
),
width: "2fr",
},
{
type: "data",
id: "createdAt",
Header: t("Date shared"),
accessor: "createdAt",
Cell: observer(({ value }: { value: string }) =>
value ? <Time dateTime={value} addSuffix /> : null
),
header: t("Date shared"),
accessor: (share) => share.createdAt,
component: (share) =>
share.createdAt ? (
<Time dateTime={share.createdAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "lastAccessedAt",
Header: t("Last accessed"),
accessor: "lastAccessedAt",
Cell: observer(({ value }: { value: string }) =>
value ? <Time dateTime={value} addSuffix /> : null
),
header: t("Last accessed"),
accessor: (share) => share.lastAccessedAt,
component: (share) =>
share.lastAccessedAt ? (
<Time dateTime={share.lastAccessedAt} addSuffix />
) : null,
width: "2fr",
},
hasDomain
? {
type: "data",
id: "domain",
Header: t("Domain"),
accessor: "domain",
disableSortBy: true,
header: t("Domain"),
accessor: (share) => share.domain,
sortable: false,
component: (share) => <>{share.domain}</>,
width: "1.5fr",
}
: undefined,
{
type: "data",
id: "views",
Header: t("Views"),
accessor: "views",
Cell: observer(({ value }: { value: number }) => (
header: t("Views"),
accessor: (share) => share.views,
component: (share) => (
<>
{language
? formatNumber(value, unicodeCLDRtoBCP47(language))
: value}
? formatNumber(share.views, unicodeCLDRtoBCP47(language))
: share.views}
</>
)),
),
width: "150px",
},
canManage
? {
Header: " ",
accessor: "id",
className: "actions",
disableSortBy: true,
Cell: observer(
({ row }: { value: string; row: { original: Share } }) => (
<Flex align="center">
<ShareMenu share={row.original} />
</Flex>
)
type: "action",
id: "action",
component: (share) => (
<Flex align="center">
<ShareMenu share={share} />
</Flex>
),
width: "50px",
}
: undefined,
].filter((i) => i),
[t, hasDomain, canManage]
]),
[t, language, hasDomain, canManage]
);
return <TableFromParams columns={columns} data={data} {...rest} />;
return (
<SortableTable
data={data}
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={HEADER_HEIGHT}
{...rest}
/>
);
}
export default SharesTable;
+9 -20
View File
@@ -7,31 +7,19 @@ import { CustomTheme } from "@shared/types";
import Storage from "@shared/utils/Storage";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import RootStore from "~/stores/RootStore";
import Policy from "~/models/Policy";
import Team from "~/models/Team";
import User from "~/models/User";
import env from "~/env";
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
import { PartialExcept } from "~/types";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
import Logger from "~/utils/Logger";
import isCloudHosted from "~/utils/isCloudHosted";
import Store from "./base/Store";
type PersistedData = {
user?: PartialExcept<User, "id">;
team?: PartialExcept<Team, "id">;
collaborationToken?: string;
availableTeams?: {
id: string;
name: string;
avatarUrl: string;
url: string;
isSignedIn: boolean;
}[];
policies?: Policy[];
};
type PersistedData = Pick<
AuthStore,
"user" | "team" | "collaborationToken" | "availableTeams" | "policies"
>;
type Provider = {
id: string;
@@ -165,9 +153,10 @@ export default class AuthStore extends Store<Team> {
/** The current team's policies */
@computed
get policies() {
return this.currentTeamId
? [this.rootStore.policies.get(this.currentTeamId)]
: [];
const policy = this.currentTeamId
? this.rootStore.policies.get(this.currentTeamId)
: undefined;
return policy ? [policy] : [];
}
/** Whether the user is signed in */
@@ -177,7 +166,7 @@ export default class AuthStore extends Store<Team> {
}
@computed
get asJson() {
get asJson(): PersistedData {
return {
user: this.user,
team: this.team,
-14
View File
@@ -150,20 +150,6 @@ export default class CommentsStore extends Store<Comment> {
return this.data.get(res.data.id) as Comment;
};
@action
setTyping({
commentId,
userId,
}: {
commentId: string;
userId: string;
}): void {
const comment = this.get(commentId);
if (comment) {
comment.typingUsers.set(userId, new Date());
}
}
@computed
get orderedData(): Comment[] {
return orderBy(Array.from(this.data.values()), "createdAt", "asc");
+28 -61
View File
@@ -1,4 +1,4 @@
import { action, autorun, computed, observable } from "mobx";
import { action, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
import Storage from "@shared/utils/Storage";
@@ -23,15 +23,16 @@ export enum SystemTheme {
Dark = "dark",
}
type PersistedData = {
languagePromptDismissed: boolean | undefined;
theme: Theme;
sidebarCollapsed: boolean;
sidebarWidth: number;
sidebarRightWidth: number;
tocVisible: boolean | undefined;
commentsExpanded: string[];
};
type PersistedData = Pick<
UiStore,
| "languagePromptDismissed"
| "commentsExpanded"
| "theme"
| "sidebarWidth"
| "sidebarRightWidth"
| "sidebarCollapsed"
| "tocVisible"
>;
class UiStore {
// has the user seen the prompt to change the UI language and actioned it
@@ -74,7 +75,7 @@ class UiStore {
sidebarCollapsed = false;
@observable
commentsExpanded: string[] = [];
commentsExpanded = false;
@observable
sidebarIsResizing = false;
@@ -98,7 +99,7 @@ class UiStore {
this.sidebarRightWidth =
data.sidebarRightWidth || defaultTheme.sidebarRightWidth;
this.tocVisible = data.tocVisible;
this.commentsExpanded = data.commentsExpanded || [];
this.commentsExpanded = !!data.commentsExpanded;
this.theme = data.theme || Theme.System;
// system theme listeners
@@ -134,10 +135,6 @@ class UiStore {
this.tocVisible = newData.tocVisible;
}
});
autorun(() => {
Storage.set(UI_STORE, this.asJson);
});
}
@action
@@ -145,14 +142,9 @@ class UiStore {
startViewTransition(() => {
flushSync(() => {
this.theme = theme;
this.persist();
});
});
Storage.set("theme", this.theme);
};
@action
setLanguagePromptDismissed = () => {
this.languagePromptDismissed = true;
};
@action
@@ -205,64 +197,35 @@ class UiStore {
this.activeCollectionId = undefined;
};
@action
setSidebarWidth = (width: number): void => {
this.sidebarWidth = width;
};
@action
setRightSidebarWidth = (width: number): void => {
this.sidebarRightWidth = width;
};
@action
collapseSidebar = () => {
this.sidebarCollapsed = true;
this.set({ sidebarCollapsed: true });
};
@action
expandSidebar = () => {
sidebarHidden = false;
this.sidebarCollapsed = false;
this.set({ sidebarCollapsed: false });
};
@action
collapseComments = (documentId: string) => {
this.commentsExpanded = this.commentsExpanded.filter(
(id) => id !== documentId
);
};
@action
expandComments = (documentId: string) => {
if (!this.commentsExpanded.includes(documentId)) {
this.commentsExpanded.push(documentId);
set = (data: Partial<PersistedData>) => {
for (const key in data) {
// @ts-expect-error doesn't understand PersistedData is subset of keys
this[key] = data[key];
}
this.persist();
};
@action
toggleComments = (documentId: string) => {
if (this.commentsExpanded.includes(documentId)) {
this.collapseComments(documentId);
} else {
this.expandComments(documentId);
}
toggleComments = () => {
this.set({ commentsExpanded: !this.commentsExpanded });
};
@action
toggleCollapsedSidebar = () => {
sidebarHidden = false;
this.sidebarCollapsed = !this.sidebarCollapsed;
};
@action
showTableOfContents = () => {
this.tocVisible = true;
};
@action
hideTableOfContents = () => {
this.tocVisible = false;
this.set({ sidebarCollapsed: !this.sidebarCollapsed });
};
@action
@@ -324,6 +287,10 @@ class UiStore {
theme: this.theme,
};
}
private persist = () => {
Storage.set(UI_STORE, this.asJson);
};
}
export default UiStore;
-55
View File
@@ -1,55 +0,0 @@
/* eslint-disable @typescript-eslint/ban-types */
import {
UsePaginationInstanceProps,
UsePaginationOptions,
UsePaginationState,
UseSortByColumnOptions,
UseSortByColumnProps,
UseSortByHooks,
UseSortByInstanceProps,
UseSortByOptions,
UseSortByState,
} from "react-table";
declare module "react-table" {
export interface TableOptions<D extends object>
extends UseExpandedOptions<D>,
UsePaginationOptions<D>,
UseSortByOptions<D>,
// note that having Record here allows you to add anything to the options, this matches the spirit of the
// underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
// feature set, this is a safe default.
Record<string, any> {}
export interface Hooks<D extends object = {}>
extends UseExpandedHooks<D>,
UseSortByHooks<D> {}
export interface TableInstance<D extends object = {}>
extends UsePaginationInstanceProps<D>,
UseSortByInstanceProps<D> {}
export interface TableState<D extends object = {}>
extends UseColumnOrderState<D>,
UseExpandedState<D>,
UsePaginationState<D>,
UseSortByState<D> {}
export interface ColumnInterface<D extends object = {}>
extends UseResizeColumnsColumnOptions<D>,
UseSortByColumnOptions<D> {}
export interface ColumnInstance<D extends object = {}>
extends UseResizeColumnsColumnProps<D>,
UseSortByColumnProps<D> {}
export interface Cell<D extends object = {}>
extends UseGroupByCellProps<D>,
UseRowStateCellProps<D> {}
export interface Row<D extends object = {}>
extends UseExpandedRowProps<D>,
UseGroupByRowProps<D>,
UseRowSelectRowProps<D>,
UseRowStateRowProps<D> {}
}
-1
View File
@@ -124,7 +124,6 @@ declare module "styled-components" {
backgroundSecondary: string;
backgroundTertiary: string;
backgroundQuaternary: string;
backgroundTransition: string;
accent: string;
accentText: string;
link: string;
+7
View File
@@ -8,6 +8,13 @@ export function homePath(): string {
return env.ROOT_SHARE_ID ? "/" : "/home";
}
export function logoutPath() {
return {
pathname: "/",
search: "logout=true",
};
}
export function draftsPath(): string {
return "/drafts";
}
+13 -12
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.616.0",
"@aws-sdk/lib-storage": "3.616.0",
"@aws-sdk/s3-presigned-post": "3.616.0",
"@aws-sdk/s3-request-presigner": "3.616.0",
"@aws-sdk/signature-v4-crt": "^3.616.0",
"@aws-sdk/client-s3": "3.693.0",
"@aws-sdk/lib-storage": "3.693.0",
"@aws-sdk/s3-presigned-post": "3.693.0",
"@aws-sdk/s3-request-presigner": "3.693.0",
"@aws-sdk/signature-v4-crt": "^3.693.0",
"@babel/core": "^7.24.7",
"@babel/plugin-proposal-decorators": "^7.24.7",
"@babel/plugin-transform-class-properties": "^7.24.7",
@@ -79,12 +79,14 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@octokit/auth-app": "^6.1.2",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.117.0",
"@sentry/node": "^7.119.0",
"@sentry/react": "^7.119.0",
"@tanstack/react-table": "^8.20.5",
"@tanstack/react-virtual": "^3.10.9",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.0",
"@types/mailparser": "^3.4.4",
@@ -203,11 +205,11 @@
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
"react-table": "^7.8.0",
"react-virtualized-auto-sizer": "^1.0.21",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.10",
"reakit": "^1.3.11",
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.2.2",
"refractor": "^3.6.0",
"request-filtering-agent": "^1.1.2",
@@ -239,7 +241,7 @@
"utility-types": "^3.10.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vite": "^5.4.10",
"vite": "^5.4.11",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.13.0",
"ws": "^7.5.10",
@@ -254,7 +256,7 @@
"@babel/cli": "^7.25.9",
"@babel/preset-typescript": "^7.24.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.12",
"@relative-ci/agent": "^4.2.13",
"@testing-library/react": "^12.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
@@ -302,10 +304,9 @@
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
"@types/react-table": "^7.7.18",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.15",
"@types/readable-stream": "^4.0.18",
"@types/redis-info": "^3.0.3",
"@types/refractor": "^3.4.1",
"@types/resolve-path": "^1.4.2",
+1 -1
View File
@@ -43,7 +43,7 @@ export default async function documentDuplicator({
};
const duplicated = await documentCreator({
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
parentDocumentId,
icon: document.icon,
color: document.color,
template: document.template,
+16 -13
View File
@@ -1,10 +1,9 @@
import { Event } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import { withAPIContext } from "@server/test/support";
import pinCreator from "./pinCreator";
describe("pinCreator", () => {
const ip = "127.0.0.1";
it("should create pin to home", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -12,11 +11,13 @@ describe("pinCreator", () => {
teamId: user.teamId,
});
const pin = await pinCreator({
documentId: document.id,
user,
ip,
});
const pin = await withAPIContext(user, (ctx) =>
pinCreator({
ctx,
user,
documentId: document.id,
})
);
const event = await Event.findLatest({
teamId: user.teamId,
@@ -36,12 +37,14 @@ describe("pinCreator", () => {
teamId: user.teamId,
});
const pin = await pinCreator({
documentId: document.id,
collectionId: document.collectionId,
user,
ip,
});
const pin = await withAPIContext(user, (ctx) =>
pinCreator({
ctx,
user,
documentId: document.id,
collectionId: document.collectionId,
})
);
const event = await Event.findLatest({
teamId: user.teamId,
+12 -37
View File
@@ -2,10 +2,12 @@ import fractionalIndex from "fractional-index";
import { Sequelize, Op, WhereOptions } from "sequelize";
import { PinValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import { Pin, User, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
import { Pin, User } from "@server/models";
import { APIContext } from "@server/types";
type Props = {
/** The request context */
ctx: APIContext;
/** The user creating the pin */
user: User;
/** The document to pin */
@@ -14,8 +16,6 @@ type Props = {
collectionId?: string | null;
/** The index to pin the document at. If no index is provided then it will be pinned to the end of the collection */
index?: string;
/** The IP address of the user creating the pin */
ip: string;
};
/**
@@ -26,10 +26,10 @@ type Props = {
* @returns Pin The pin that was created
*/
export default async function pinCreator({
ctx,
user,
documentId,
collectionId,
ip,
...rest
}: Props): Promise<Pin> {
let { index } = rest;
@@ -62,38 +62,13 @@ export default async function pinCreator({
index = fractionalIndex(pins.length ? pins[0].index : null, null);
}
const transaction = await sequelize.transaction();
let pin;
try {
pin = await Pin.create(
{
createdById: user.id,
teamId: user.teamId,
collectionId,
documentId,
index,
},
{ transaction }
);
await Event.create(
{
name: "pins.create",
modelId: pin.id,
teamId: user.teamId,
actorId: user.id,
documentId,
collectionId,
ip,
},
{ transaction }
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
const pin = await Pin.createWithCtx(ctx, {
createdById: user.id,
teamId: user.teamId,
collectionId,
documentId,
index,
});
return pin;
}
+2
View File
@@ -13,6 +13,8 @@ type Props = {
};
/**
* @deprecated use pin.destroyWithCtx instead. This will be removed once document routes migrate to auto event insertion using APIContext.
*
* This command destroys a document pin. This just removes the pin itself and
* does not touch the document
*
-53
View File
@@ -1,53 +0,0 @@
import { Event, Pin, User } from "@server/models";
import { sequelize } from "@server/storage/database";
type Props = {
/** The user updating the pin */
user: User;
/** The existing pin */
pin: Pin;
/** The index to pin the document at */
index: string;
/** The IP address of the user creating the pin */
ip: string;
};
/**
* This command updates a "pinned" document. A pin can only be moved to a new
* index (reordered) once created.
*
* @param Props The properties of the pin to update
* @returns Pin The updated pin
*/
export default async function pinUpdater({
user,
pin,
index,
ip,
}: Props): Promise<Pin> {
const transaction = await sequelize.transaction();
try {
pin.index = index;
await pin.save({ transaction });
await Event.create(
{
name: "pins.update",
modelId: pin.id,
teamId: user.teamId,
actorId: user.id,
documentId: pin.documentId,
collectionId: pin.collectionId,
ip,
},
{ transaction }
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
return pin;
}
+2 -2
View File
@@ -11,7 +11,7 @@ const useTestEmailService = env.isDevelopment && !env.SMTP_USERNAME;
type SendMailOptions = {
to: string;
from?: EmailAddress | string;
from: EmailAddress;
replyTo?: string;
messageId?: string;
references?: string[];
@@ -143,7 +143,7 @@ export class Mailer {
Logger.info("email", `Sending email "${data.subject}" to ${data.to}`);
const info = await transporter.sendMail({
from: env.isCloudHosted && data.from ? data.from : env.SMTP_FROM_EMAIL,
from: data.from,
replyTo: data.replyTo ?? env.SMTP_REPLY_EMAIL ?? env.SMTP_FROM_EMAIL,
to: data.to,
messageId: data.messageId,
+18 -13
View File
@@ -1,4 +1,4 @@
import addressparser from "addressparser";
import addressparser, { EmailAddress } from "addressparser";
import Bull from "bull";
import invariant from "invariant";
import { Node } from "prosemirror-model";
@@ -51,6 +51,15 @@ export default abstract class BaseEmail<
* @returns A promise that resolves once the email is placed on the task queue
*/
public schedule(options?: Bull.JobOptions) {
// No-op to schedule emails if SMTP is not configured
if (!env.SMTP_FROM_EMAIL) {
Logger.info(
"email",
`Email ${this.constructor.name} not sent due to missing SMTP configuration`
);
return;
}
const templateName = this.constructor.name;
Metrics.increment("email.scheduled", {
@@ -175,26 +184,22 @@ export default abstract class BaseEmail<
}
}
private from(props: S & T) {
private from(props: S & T): EmailAddress {
invariant(
env.SMTP_FROM_EMAIL,
"SMTP_FROM_EMAIL is required to send emails"
);
const parsedFrom = addressparser(env.SMTP_FROM_EMAIL)[0];
const name = this.fromName?.(props);
if (this.category === EmailMessageCategory.Authentication) {
const domain = parsedFrom.address.split("@")[1];
return {
name: name ?? parsedFrom.name,
address: `noreply-${randomstring.generate(24)}@${domain}`,
};
}
const domain = parsedFrom.address.split("@")[1];
return {
name: name ?? parsedFrom.name,
address: parsedFrom.address,
name: this.fromName?.(props) ?? parsedFrom.name,
address:
env.isCloudHosted &&
this.category === EmailMessageCategory.Authentication
? `noreply-${randomstring.generate(24)}@${domain}`
: parsedFrom.address,
};
}
@@ -7,6 +7,7 @@ import HTMLHelper from "@server/models/helpers/HTMLHelper";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
import { can } from "@server/policies";
import { CacheHelper } from "@server/utils/CacheHelper";
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
@@ -68,21 +69,28 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
let body;
if (revisionId && team?.getPreference(TeamPreference.PreviewsInEmails)) {
// generate the diff html for the email
const revision = await Revision.findByPk(revisionId);
body = await CacheHelper.getDataOrSet<string>(
`diff:${revisionId}`,
async () => {
// generate the diff html for the email
const revision = await Revision.findByPk(revisionId);
if (revision) {
const before = await revision.before();
const content = await DocumentHelper.toEmailDiff(before, revision, {
includeTitle: false,
centered: false,
signedUrls: 4 * Day.seconds,
baseUrl: props.teamUrl,
});
if (revision) {
const before = await revision.before();
const content = await DocumentHelper.toEmailDiff(before, revision, {
includeTitle: false,
centered: false,
signedUrls: 4 * Day.seconds,
baseUrl: props.teamUrl,
});
// inline all css so that it works in as many email providers as possible.
body = content ? await HTMLHelper.inlineCSS(content) : undefined;
}
// inline all css so that it works in as many email providers as possible.
return content ? await HTMLHelper.inlineCSS(content) : undefined;
}
return;
},
30
);
}
return {
@@ -0,0 +1,21 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn("teams", "approximateTotalAttachmentsSize", {
type: Sequelize.BIGINT,
defaultValue: 0,
}, { transaction });
await queryInterface.addIndex("attachments", ["createdAt"], { transaction });
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeIndex("attachments", ["createdAt"], { transaction });
await queryInterface.removeColumn("teams", "approximateTotalAttachmentsSize", { transaction });
});
},
};
+2
View File
@@ -20,6 +20,8 @@ class Pin extends IdModel<
InferAttributes<Pin>,
Partial<InferCreationAttributes<Pin>>
> {
static eventNamespace = "pins";
@Length({
max: 256,
msg: `index must be 256 characters or less`,
+6
View File
@@ -25,6 +25,7 @@ import {
AfterUpdate,
BeforeUpdate,
BeforeCreate,
IsNumeric,
} from "sequelize-typescript";
import { TeamPreferenceDefaults } from "@shared/constants";
import { TeamPreference, TeamPreferences, UserRole } from "@shared/types";
@@ -151,6 +152,11 @@ class Team extends ParanoidModel<
@Column(DataType.STRING)
defaultUserRole: UserRole;
/** Approximate size in bytes of all attachments in the team. */
@IsNumeric
@Column(DataType.BIGINT)
approximateTotalAttachmentsSize: number;
@AllowNull
@Column(DataType.JSONB)
preferences: TeamPreferences | null;
+1 -1
View File
@@ -558,7 +558,7 @@ export class ProsemirrorHelper {
// Inject Mermaid script
if (mermaidElements.length) {
element.innerHTML = `
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@9/dist/mermaid.esm.min.mjs';
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({
startOnLoad: true,
fontFamily: "inherit",
@@ -0,0 +1,31 @@
import { Attachment, Team } from "@server/models";
import BaseTask, { TaskPriority } from "./BaseTask";
type Props = {
/** The teamId to operate on */
teamId: string;
};
/**
* A task that updates the team stats.
*/
export default class UpdateTeamAttachmentsSizeTask extends BaseTask<Props> {
public async perform({ teamId }: Props) {
const sizeInBytes = await Attachment.getTotalSizeForTeam(teamId);
if (!sizeInBytes) {
return;
}
await Team.update(
{ approximateTotalAttachmentsSize: sizeInBytes },
{ where: { id: teamId } }
);
}
public get options() {
return {
attempts: 1,
priority: TaskPriority.Background,
};
}
}
@@ -0,0 +1,53 @@
import { subDays } from "date-fns";
import { Op } from "sequelize";
import Logger from "@server/logging/Logger";
import { Attachment } from "@server/models";
import { sequelize } from "@server/storage/database";
import BaseTask, { TaskPriority, TaskSchedule } from "./BaseTask";
import UpdateTeamAttachmentsSizeTask from "./UpdateTeamAttachmentsSizeTask";
type Props = {
limit: number;
};
export default class UpdateTeamsAttachmentsSizeTask extends BaseTask<Props> {
static cron = TaskSchedule.Daily;
public async perform({ limit }: Props) {
Logger.info(
"task",
`Recalculating attachment sizes for upto ${limit} teams…`
);
// Find unique attachment teamIds created in the last day, update only
// those teams' approximate attachment sizes
await Attachment.findAllInBatches<Attachment>(
{
attributes: [
[sequelize.fn("DISTINCT", sequelize.col("teamId")), "teamId"],
],
where: {
createdAt: {
[Op.gt]: subDays(new Date(), 1),
},
},
batchLimit: 100,
raw: true,
},
async (rows) => {
const teamIds = rows.map((row) => row.teamId);
for (const teamId of teamIds) {
await UpdateTeamAttachmentsSizeTask.schedule({ teamId });
}
}
);
}
public get options() {
return {
attempts: 1,
priority: TaskPriority.Background,
};
}
}
+25 -14
View File
@@ -1,9 +1,8 @@
import Router from "koa-router";
import { Sequelize, Op } from "sequelize";
import { Sequelize, Op, Transaction } from "sequelize";
import pinCreator from "@server/commands/pinCreator";
import pinDestroyer from "@server/commands/pinDestroyer";
import pinUpdater from "@server/commands/pinUpdater";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Collection, Document, Pin } from "@server/models";
import { authorize } from "@server/policies";
@@ -22,18 +21,21 @@ router.post(
"pins.create",
auth(),
validate(T.PinsCreateSchema),
transaction(),
async (ctx: APIContext<T.PinsCreateReq>) => {
const { documentId, collectionId, index } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const document = await Document.findByPk(documentId, {
userId: user.id,
transaction,
});
authorize(user, "read", document);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
}).findByPk(collectionId, { transaction });
authorize(user, "update", collection);
authorize(user, "pin", document);
} else {
@@ -41,10 +43,10 @@ router.post(
}
const pin = await pinCreator({
ctx,
user,
documentId,
collectionId,
ip: ctx.request.ip,
index,
});
@@ -108,13 +110,20 @@ router.post(
"pins.update",
auth(),
validate(T.PinsUpdateSchema),
transaction(),
async (ctx: APIContext<T.PinsUpdateReq>) => {
const { id, index } = ctx.input.body;
const { user } = ctx.state.auth;
let pin = await Pin.findByPk(id, { rejectOnEmpty: true });
const { transaction } = ctx.state;
const pin = await Pin.findByPk(id, {
transaction,
lock: Transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
const document = await Document.findByPk(pin.documentId, {
userId: user.id,
transaction,
});
if (pin.collectionId) {
@@ -123,12 +132,7 @@ router.post(
authorize(user, "update", pin);
}
pin = await pinUpdater({
user,
pin,
ip: ctx.request.ip,
index,
});
await pin.updateWithCtx(ctx, { index });
ctx.body = {
data: presentPin(pin),
@@ -141,14 +145,21 @@ router.post(
"pins.delete",
auth(),
validate(T.PinsDeleteSchema),
transaction(),
async (ctx: APIContext<T.PinsDeleteReq>) => {
const { id } = ctx.input.body;
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const pin = await Pin.findByPk(id, { rejectOnEmpty: true });
const pin = await Pin.findByPk(id, {
transaction,
lock: Transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
const document = await Document.findByPk(pin.documentId, {
userId: user.id,
transaction,
});
if (pin.collectionId) {
@@ -157,7 +168,7 @@ router.post(
authorize(user, "delete", pin);
}
await pinDestroyer({ user, pin, ip: ctx.request.ip });
await pin.destroyWithCtx(ctx);
ctx.body = {
success: true,
+36 -32
View File
@@ -1,6 +1,6 @@
import Router from "koa-router";
import isUndefined from "lodash/isUndefined";
import { Op, WhereOptions } from "sequelize";
import { FindOptions, Op, WhereOptions } from "sequelize";
import { NotFoundError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
@@ -116,43 +116,47 @@ router.post(
const collectionIds = await user.collectionIds();
const options: FindOptions = {
where,
include: [
{
model: Document,
required: true,
paranoid: true,
as: "document",
where: {
collectionId: collectionIds,
},
include: [
{
model: Collection.scope({
method: ["withMembership", user.id],
}),
as: "collection",
},
],
},
{
model: User,
required: true,
as: "user",
},
{
model: Team,
required: true,
as: "team",
},
],
};
const [shares, total] = await Promise.all([
Share.findAll({
where,
...options,
order: [[sort, direction]],
include: [
{
model: Document,
required: true,
paranoid: true,
as: "document",
where: {
collectionId: collectionIds,
},
include: [
{
model: Collection.scope({
method: ["withMembership", user.id],
}),
as: "collection",
},
],
},
{
model: User,
required: true,
as: "user",
},
{
model: Team,
required: true,
as: "team",
},
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Share.count({ where }),
Share.count(options),
]);
ctx.body = {
+4 -2
View File
@@ -145,14 +145,13 @@ export default class S3Storage extends BaseStorage {
const params = {
Bucket: this.getBucket(),
Key: key,
Expires: expiresIn,
};
if (isDocker) {
return `${this.getPublicEndpoint()}/${key}`;
} else {
const command = new GetObjectCommand(params);
const url = await getSignedUrl(this.client, command);
const url = await getSignedUrl(this.client, command, { expiresIn });
if (env.AWS_S3_ACCELERATE_URL) {
return url.replace(
@@ -231,6 +230,9 @@ export default class S3Storage extends BaseStorage {
if (env.AWS_S3_UPLOAD_BUCKET_NAME) {
const url = new URL(env.AWS_S3_UPLOAD_BUCKET_URL);
if (url.hostname.startsWith(env.AWS_S3_UPLOAD_BUCKET_NAME + ".")) {
Logger.warn(
"AWS_S3_UPLOAD_BUCKET_URL contains the bucket name, this configuration combination will always point to AWS.\nRename your bucket or hostname if not using AWS S3.\nSee: https://github.com/outline/outline/issues/8025"
);
return undefined;
}
}
+49
View File
@@ -1,6 +1,7 @@
import { Day } from "@shared/utils/time";
import Logger from "@server/logging/Logger";
import Redis from "@server/storage/redis";
import { MutexLock } from "./MutexLock";
/**
* A Helper class for server-side cache management
@@ -9,6 +10,54 @@ export class CacheHelper {
// Default expiry time for cache data in seconds
private static defaultDataExpiry = Day.seconds;
/**
* Given a key this method will attempt to get the data from cache store first
* If data is not found, it will call the callback to get the data and save it in cache
* using a distributed lock to prevent multiple writes.
*
* @param key Cache key
* @param callback Callback to get the data if not found in cache
* @param expiry Cache data expiry in seconds
*/
public static async getDataOrSet<T>(
key: string,
callback: () => Promise<T | undefined>,
expiry?: number
): Promise<T | undefined> {
let cache = await this.getData<T>(key);
if (cache) {
return cache;
}
// Nothing in the cache, acquire a lock to prevent multiple writes
let lock;
const lockKey = `lock:${key}`;
try {
try {
lock = await MutexLock.lock.acquire(
[lockKey],
MutexLock.defaultLockTimeout
);
} catch (err) {
Logger.error(`Could not acquire lock for ${key}`, err);
}
cache = await this.getData<T>(key);
if (cache) {
return cache;
}
// Get the data from the callback and save it in cache
const value = await callback();
if (value) {
await this.setData<T>(key, value, expiry);
}
return value;
} finally {
await lock?.release();
}
}
/**
* Given a key, gets the data from cache store
*
+22
View File
@@ -0,0 +1,22 @@
import Redlock from "redlock";
import Redis from "@server/storage/redis";
export class MutexLock {
// Default expiry time for acquiring lock in milliseconds
public static defaultLockTimeout = 4000;
/**
* Returns the redlock instance
*/
public static get lock(): Redlock {
this.redlock ??= new Redlock([Redis.defaultClient], {
retryJitter: 10,
retryCount: 20,
retryDelay: 200,
});
return this.redlock;
}
private static redlock: Redlock;
}
+2 -1
View File
@@ -7,7 +7,7 @@ import { getCookieDomain } from "@shared/utils/domains";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { Event, Collection, View } from "@server/models";
import { AuthenticationResult } from "@server/types";
import { AuthenticationResult, AuthenticationType } from "@server/types";
/**
* Parse and return the details from the "sessions" cookie in the request, if
@@ -68,6 +68,7 @@ export async function signIn(
actorId: user.id,
userId: user.id,
teamId: team.id,
authType: AuthenticationType.APP,
data: {
name: user.name,
service,

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