Compare commits

...

79 Commits

Author SHA1 Message Date
Tom Moor 4e19e7131d feedback 2024-10-27 15:10:05 -05:00
Tom Moor 60236da65b Make from address for authentication-related emails unguessable 2024-10-27 09:00:12 -05:00
Tom Moor 7bdae0cbda Revert "fix: Remove overflow from floating toolbar in desktop, as it sometimes causes the content to be misplaced"
This reverts commit bb988b551d.

Closes #7836
2024-10-26 10:14:49 -04:00
Hemachandar 3692d9c930 fix: move editor init to dispatchTransaction (#7833) 2024-10-25 08:33:18 -07:00
Alexandr Zagorskiy 2e1a827157 Feat/installation info endpoint (#7744)
* feat: add installation.info endpoint using DockerHub API

* feat: UI use an server-side API to show version info

* fix: review fixes

* test: installation.info endpoint

* feat: filtering pre-releases in installation.info endpoint

* fix: change fetch to ApiClient usage for getting version info

* Undo translation change

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-10-25 06:39:47 -07:00
Hemachandar fe33871dfe fix: wait for shared document to load (#7830) 2024-10-25 05:37:59 -07:00
Tom Moor f22bd1d7c8 perf: Multitude of small perf wins around comment sidebar, closes #7823 2024-10-24 19:22:22 -04:00
Tom Moor 48ff0ad84b fix: Duplicate threads in sidebar when comment mark crosses boundary 2024-10-24 09:45:20 -04:00
Tom Moor 4f626c08c2 perf: Fix comments double rendering on mount (#7824) 2024-10-24 05:46:43 -07:00
Hemachandar 57e9abd77f feat: allow sort by position for comments (#7770)
* feat: allow sort by position for comments

* wait for prosemirror nodes to load

* Move to menu

* remove sort; rename enum

* asc sort for in-thread display

* revert sort

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-10-22 20:18:33 -07:00
Apoorv Mishra 0d7ce76c21 Allow querying by user emails in order to @mention them (#7807)
* fix: readEmail permission

* fix: allow querying over user email in users.list

* fix: allow searching by email in @mention

* fix: include email in mentioned user's hover card

* fix: put email on separate line in hover card
2024-10-22 20:24:11 +05:30
Tom Moor c8d307c2d4 fix: Improve safety around image toolbar, related #7815 2024-10-21 21:42:27 -04:00
Tom Moor 10c51ef08d fix: Add syntax highlighting for Mermaid diagrams 2024-10-21 21:22:48 -04:00
Tom Moor bb988b551d fix: Remove overflow from floating toolbar in desktop, as it sometimes causes the content to be misplaced 2024-10-21 21:03:29 -04:00
dependabot[bot] 0e75edf7e3 chore(deps): bump prosemirror-markdown from 1.13.0 to 1.13.1 (#7811)
* chore(deps): bump prosemirror-markdown from 1.13.0 to 1.13.1

Bumps [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown) from 1.13.0 to 1.13.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-markdown/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-markdown/compare/1.13.0...1.13.1)

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

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

* tsc

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-10-21 17:50:32 -07:00
dependabot[bot] 3523ee4c35 chore(deps-dev): bump @relative-ci/agent from 4.2.9 to 4.2.12 (#7810)
Bumps [@relative-ci/agent](https://github.com/relative-ci/agent) from 4.2.9 to 4.2.12.
- [Release notes](https://github.com/relative-ci/agent/releases)
- [Commits](https://github.com/relative-ci/agent/compare/v4.2.9...v4.2.12)

---
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-10-21 17:25:51 -07:00
dependabot[bot] c0fba3913c chore(deps-dev): bump @types/turndown from 5.0.4 to 5.0.5 (#7812)
Bumps [@types/turndown](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/turndown) from 5.0.4 to 5.0.5.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/turndown)

---
updated-dependencies:
- dependency-name: "@types/turndown"
  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-10-21 17:25:37 -07:00
dependabot[bot] 597106cb48 chore(deps): bump prosemirror-transform from 1.10.0 to 1.10.2 (#7813)
Bumps [prosemirror-transform](https://github.com/prosemirror/prosemirror-transform) from 1.10.0 to 1.10.2.
- [Changelog](https://github.com/ProseMirror/prosemirror-transform/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-transform/compare/1.10.0...1.10.2)

---
updated-dependencies:
- dependency-name: prosemirror-transform
  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-10-21 17:25:27 -07:00
Tom Moor 02c29e06fb perf: filter -> find to reduce policies iterated through (#7816) 2024-10-21 17:24:49 -07:00
Tom Moor 7226109989 perf: Avoid expensive DocumentHelper.toMarkdown call in presenter 2024-10-21 19:23:01 -04:00
Tom Moor 85957c10b8 fix: Invalid regex error bubbles from FindAndReplace 2024-10-20 21:27:22 -04:00
Tom Moor 7250bd3bcb fix: Cannot scrub videos in Chrome when using local storage
closes #7517
2024-10-20 21:21:51 -04:00
Tom Moor 2ee7e0f832 Use view transition API to smoothly transition between light/dark theme 2024-10-20 20:02:46 -04:00
Tom Moor c5278a71de Increase ping/pong rate to increase Heroku compatibility 2024-10-20 09:56:59 -04:00
Tom Moor e41519575f tsc 2024-10-19 12:37:17 -04:00
Tom Moor 201ccf39a0 Update icon on disclosure buttons/selects 2024-10-19 12:25:54 -04:00
Tom Moor ac3285a29a tsc 2024-10-19 08:54:58 -04:00
Tom Moor fdaeb6602d fix: Support diacritics in cmd+f, closes #7801 2024-10-19 08:22:20 -04:00
Tom Moor da4cd4ebcd Improved error handling for Azure auth, add default value for AZURE_RESOURCE_ID 2024-10-19 08:05:43 -04:00
Tom Moor b6fc8fb4b1 fix: Guard unset in awareness data 2024-10-18 09:00:02 -04:00
Tom Moor 4e6572d686 fix: Mutate clipboard content when copying from a single table cell. (#7798)
* fix: Mutate clipboard content when copying from a single table cell.
closes #7794

* refactor
2024-10-18 05:35:21 -07:00
Tom Moor 9e378899ff Remove string filtering in logger 2024-10-17 22:50:11 -04:00
Tom Moor 31dafc4258 Hide remote users selections after a timeout (#7788) 2024-10-17 15:38:36 -07:00
Hemachandar 6614b23eae fix: assorted comment bugs (#7795)
* fix: assorted comment bugs

* remove policy instead of force fetch
2024-10-17 15:38:26 -07:00
Tom Moor 9e54fd1bfb fix: User exists should account for deleted workspaces, closes #7793 2024-10-17 18:14:15 -04:00
Tom Moor f0add849f9 fix: Ensure max filename length for stored attachments, closes #7785 2024-10-16 23:18:18 -04:00
Tom Moor b55915c257 fix: Include deleted workspaces when searching for available subdomains, closes #7787 2024-10-16 22:59:22 -04:00
Tom Moor bdac4360b4 chore: Remove usage of y-prosemirror fork, pull in latest fixes from upstream 2024-10-16 21:37:52 -04:00
Tom Moor 72bfbf2060 Allow returning team API keys for admins from apiKeys.list (#7766)
* Allow returning team apiKeys.list for admins from apiKeys.list

* Filter apikeys in store
2024-10-14 15:29:47 -07:00
dependabot[bot] db02b0ae6b chore(deps): bump @babel/preset-env from 7.25.7 to 7.25.8 (#7780)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.25.7 to 7.25.8.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.25.8/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  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-10-14 14:30:10 -07:00
dependabot[bot] bb40e4079a chore(deps-dev): bump nodemon from 3.1.4 to 3.1.7 (#7781)
Bumps [nodemon](https://github.com/remy/nodemon) from 3.1.4 to 3.1.7.
- [Release notes](https://github.com/remy/nodemon/releases)
- [Commits](https://github.com/remy/nodemon/compare/v3.1.4...v3.1.7)

---
updated-dependencies:
- dependency-name: nodemon
  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-10-14 14:29:58 -07:00
dependabot[bot] 198a96c78f chore(deps): bump emoji-regex from 10.3.0 to 10.4.0 (#7783)
Bumps [emoji-regex](https://github.com/mathiasbynens/emoji-regex) from 10.3.0 to 10.4.0.
- [Commits](https://github.com/mathiasbynens/emoji-regex/compare/v10.3.0...v10.4.0)

---
updated-dependencies:
- dependency-name: emoji-regex
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-14 14:29:43 -07:00
dependabot[bot] 1dd835bb87 chore(deps-dev): bump discord-api-types from 0.37.101 to 0.37.102 (#7779)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.37.101 to 0.37.102.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.37.101...0.37.102)

---
updated-dependencies:
- dependency-name: discord-api-types
  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-10-14 14:29:15 -07:00
dependabot[bot] 25c504ceaf chore(deps-dev): bump typescript from 5.4.5 to 5.6.3 (#7767)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.4.5 to 5.6.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.4.5...v5.6.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-11 19:45:02 -07:00
Tom Moor 9680e57849 chore: Remove suppressImplicitAnyIndexErrors TS rule (#7760) 2024-10-11 12:46:46 -07:00
Hemachandar 0f8ac54bcb feat: include content in document mentioned email (#7756)
* feat: include content in document mentioned email

* handle doc publish flow

* add tests, doc

* including heading node

* Diff border
2024-10-11 12:30:08 -07:00
Hemachandar 936a8b2510 fix: show all document backlinks for a user (#7751)
* fix: show all document backlinks for a user

* add findByIds method to Document model

* default options param

* move filter to Document model

* docs

* fix: Backlinks from collections without direct membership not returned

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-10-11 08:38:24 -07:00
Luke Thomas b7b5e3edb9 fix: Remove docker version in compose file (#7762) 2024-10-11 06:26:38 -07:00
Translate-O-Tron 1cea59abe2 New Crowdin updates (#7730) 2024-10-10 18:22:12 -07:00
Tom Moor 8f0211057c fix: RTL headings are not considered separately for layout
closes #7757
2024-10-10 20:45:50 -04:00
Tom Moor 2bfef05137 fix: Mention with space in search is not inserted correctly, closes #7759 2024-10-10 19:59:20 -04:00
dependabot[bot] d2a99b6872 chore(deps): bump vite from 5.3.1 to 5.4.8 (#7704)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.3.1 to 5.4.8.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.8/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.8/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-10 04:47:20 -07:00
Hemachandar 6c9f265918 feat: Lossless JSON import (#7274)
* feat: Lossless JSON import

* transform node only when attachments are present in the zip
2024-10-09 19:04:04 -07:00
Tom Moor 7a8d40b9e7 feat: Add option to export table as CSV, closes #7743 2024-10-08 21:23:38 -04:00
Tom Moor 3ddffdda17 fix: Race condition rendering Mermaid diagrams in dark mode 2024-10-08 20:43:48 -04:00
Tom Moor 91396148ae fix: “Share to web” control is unresponsive when opening via “Permissions” menu item 2024-10-08 19:20:40 -04:00
Tom Moor 1c2ea2aa92 fix: Incorrect keyboard shortcut for TOC shown on macOS 2024-10-08 18:55:19 -04:00
Tom Moor ba5eb60825 fix: Remove slashes and literal newlines from markdown, closes #7691 2024-10-07 23:01:07 -04:00
Tom Moor a0e363799c fix: Add extra safety around search queries 2024-10-07 22:29:54 -04:00
Tom Moor 3d457890cd fix: Regression in e857d00e3d rendering embeds 2024-10-07 22:04:51 -04:00
Tom Moor e857d00e3d chore: Moves ProseMirror NodeView to render within main React context (#7736) 2024-10-07 17:58:00 -07:00
Tom Moor 98d8435b15 Allow search page to work with Firefox keywords, closes #7722 2024-10-07 19:55:19 -04:00
dependabot[bot] b80463665b chore(deps): bump @babel/preset-env from 7.24.7 to 7.25.7 (#7740)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.24.7 to 7.25.7.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.25.7/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 16:54:55 -07:00
dependabot[bot] b4ce4a2922 chore(deps): bump prosemirror-model from 1.22.3 to 1.23.0 (#7741)
Bumps [prosemirror-model](https://github.com/prosemirror/prosemirror-model) from 1.22.3 to 1.23.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-model/compare/1.22.3...1.23.0)

---
updated-dependencies:
- dependency-name: prosemirror-model
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 16:54:45 -07:00
dependabot[bot] 9bee54b07e chore(deps-dev): bump @types/react-avatar-editor from 13.0.2 to 13.0.3 (#7739)
Bumps [@types/react-avatar-editor](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-avatar-editor) from 13.0.2 to 13.0.3.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-avatar-editor)

---
updated-dependencies:
- dependency-name: "@types/react-avatar-editor"
  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-10-07 16:54:35 -07:00
Tom Moor d3c8224839 fix: Error during import with long filenames (#7738)
* fix: Stream error during import causes worker restart

* refactor

* fix: Ensure we never write filenames longer than the system can handle
2024-10-07 05:36:18 -07:00
Tom Moor 0a1c614c55 fix: Addressed several React warnings in icon picker 2024-10-06 11:38:24 -04:00
Tom Moor db4dad5e37 fix: Enter key while renaming item in sidebar should persist
fix: Renaming item in sidebar should not navigate to collection
2024-10-06 11:17:39 -04:00
Apoorv Mishra 35ff70bf14 Archive collections (#7266)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-10-06 05:37:11 -07:00
Tom Moor 8b5fdba6f4 chore: Remove usage of deprecated docker build image 2024-10-05 12:51:38 -04:00
dependabot[bot] e0a3ad92e0 chore(deps): bump cookie from 0.6.0 to 0.7.0 (#7734)
Bumps [cookie](https://github.com/jshttp/cookie) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/jshttp/cookie/releases)
- [Commits](https://github.com/jshttp/cookie/compare/v0.6.0...v0.7.0)

---
updated-dependencies:
- dependency-name: cookie
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-05 08:21:33 -07:00
Tom Moor 10f4889737 fix: Cloned response on network error can cause process to hang (remove) 2024-10-05 10:59:56 -04:00
Tom Moor 7f66393e63 spelling 2024-10-03 21:51:07 -04:00
Tom Moor 033b05f679 fix: User cannot update profile when MembersCanDeleteAccount setting is disabled, closes #7729 2024-10-03 20:25:35 -04:00
Tom Moor 8356d44cae Merge branch 'main' of github.com:outline/outline 2024-10-03 19:39:06 -04:00
Translate-O-Tron 030c0fd40e New Crowdin updates (#7641) 2024-10-03 16:32:38 -07:00
Tom Moor 1a02b0d9d7 Add script to backfill ApiKey hashes (#7717)
* Add hashed column for API keys

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2024-10-03 16:27:25 -07:00
Apoorv Mishra be5f092117 Show nested docs on Archive page (#7488)
* fix: nested docs should appear in archive

* fix(app): ArchivableModel

* fix(server): ArchivableModel

* fix: PartialWithArchivedAt not needed

* fix: new PartialExcept type

* fix: restore deletion

* fix: review
2024-10-02 10:10:41 +05:30
Tom Moor 0ba423feb4 fix: Improve empty state for command menu no results 2024-10-01 22:28:38 -04:00
286 changed files with 6636 additions and 2690 deletions
+1 -2
View File
@@ -108,8 +108,7 @@ jobs:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker:
version: 20.10.6
- setup_remote_docker
- run:
name: Install Docker buildx
command: |
+84 -3
View File
@@ -1,8 +1,10 @@
import {
ArchiveIcon,
CollectionIcon,
EditIcon,
PadlockIcon,
PlusIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
StarredIcon,
@@ -10,11 +12,13 @@ import {
UnstarredIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -129,9 +133,20 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).readDocument,
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection?.isActive) {
return false;
}
return stores.policies.abilities(activeCollectionId).readDocument;
},
perform: ({ activeCollectionId }) => {
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
},
@@ -190,6 +205,72 @@ export const unstarCollection = createAction({
},
});
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
section: CollectionSection,
icon: <ArchiveIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).archive;
},
perform: async ({ activeCollectionId, stores, t }) => {
const { dialogs, collections } = stores;
if (!activeCollectionId) {
return;
}
const collection = collections.get(activeCollectionId);
if (!collection) {
return;
}
dialogs.openModal({
title: t("Archive collection"),
content: (
<ConfirmationDialog
onSubmit={async () => {
await collection.archive();
toast.success(t("Collection archived"));
}}
submitText={t("Archive")}
savingText={`${t("Archiving")}`}
>
{t(
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results."
)}
</ConfirmationDialog>
),
});
},
});
export const restoreCollection = createAction({
name: ({ t }) => t("Restore"),
analyticsName: "Restore collection",
section: CollectionSection,
icon: <RestoreIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).restore;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
await collection.restore();
toast.success(t("Collection restored"));
},
});
export const deleteCollection = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
-4
View File
@@ -358,8 +358,6 @@ export const shareDocument = createAction({
}
const document = stores.documents.get(activeDocumentId);
const share = stores.shares.getByDocumentId(activeDocumentId);
const sharedParent = stores.shares.getByDocumentParents(activeDocumentId);
if (!document) {
return;
}
@@ -370,8 +368,6 @@ export const shareDocument = createAction({
content: (
<SharePopover
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={stores.dialogs.closeAllModals}
visible
/>
+6 -2
View File
@@ -1,5 +1,5 @@
import { LocationDescriptor } from "history";
import { ExpandedIcon } from "outline-icons";
import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
@@ -189,10 +189,14 @@ const Button = <T extends React.ElementType = "button">(
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && ic}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
{disclosure && <ExpandedIcon />}
{disclosure && <StyledDisclosureIcon />}
</Inner>
</RealButton>
);
};
const StyledDisclosureIcon = styled(DisclosureIcon)`
opacity: 0.8;
`;
export default React.forwardRef(Button);
+45
View File
@@ -0,0 +1,45 @@
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { MenuInternalLink } from "~/types";
import { archivePath, collectionPath } from "~/utils/routeHelpers";
import Breadcrumb from "./Breadcrumb";
type Props = {
collection: Collection;
};
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
const { t } = useTranslation();
const items = React.useMemo(() => {
const collectionNode: MenuInternalLink = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
};
const category: MenuInternalLink | undefined = collection.isArchived
? {
type: "route",
icon: <ArchiveIcon />,
title: t("Archive"),
to: archivePath(),
}
: undefined;
const output = [];
if (category) {
output.push(category);
}
output.push(collectionNode);
return output;
}, [collection, t]);
return <Breadcrumb items={items} highlightFirstItem />;
};
+4 -1
View File
@@ -77,7 +77,10 @@ const SearchInput = styled(KBarSearch)`
border: none;
background: ${s("menuBackground")};
color: ${s("text")};
border-bottom: 1px solid ${s("inputBorder")};
&:not(:last-child) {
border-bottom: 1px solid ${s("inputBorder")};
}
&:disabled,
&::placeholder {
@@ -7,6 +7,10 @@ import CommandBarItem from "./CommandBarItem";
export default function CommandBarResults() {
const { results, rootActionId } = useMatches();
if (results.length === 0) {
return null;
}
return (
<Container>
<KBarResults
+7 -5
View File
@@ -21,11 +21,13 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
const { documents, dialogs, collections } = useStores();
const { t } = useTranslation();
const prevCollection = collections.get(item.collectionId!);
const accessMapping = {
[CollectionPermission.ReadWrite]: t("view and edit access"),
[CollectionPermission.Read]: t("view only access"),
null: t("no access"),
};
const accessMapping: Record<Partial<CollectionPermission> | "null", string> =
{
[CollectionPermission.Admin]: t("manage access"),
[CollectionPermission.ReadWrite]: t("view and edit access"),
[CollectionPermission.Read]: t("view only access"),
null: t("no access"),
};
const handleSubmit = async () => {
await documents.move({
+1 -1
View File
@@ -35,7 +35,7 @@ function ConnectionStatus() {
};
const message = ui.multiplayerErrorCode
? codeToMessage[ui.multiplayerErrorCode]
? codeToMessage[ui.multiplayerErrorCode as keyof typeof codeToMessage]
: undefined;
return ui.multiplayerStatus === "connecting" ||
+8
View File
@@ -11,6 +11,9 @@ class DocumentContext {
/** The editor instance for this document */
editor?: Editor;
@observable
isEditorInitialized: boolean = false;
@observable
headings: Heading[] = [];
@@ -31,6 +34,11 @@ class DocumentContext {
this.updateState();
};
@action
setEditorInitialized = (initialized: boolean) => {
this.isEditorInitialized = initialized;
};
@action
updateState = () => {
this.updateHeadings();
@@ -125,6 +125,7 @@ function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
{ avatarUrl, name, lastActive, color }: Props,
{ avatarUrl, name, lastActive, color, email }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
@@ -25,6 +25,7 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
/>
<Flex column gap={2} justify="center">
<Title>{name}</Title>
{email && <Info>{email}</Info>}
<Info>{lastActive}</Info>
</Flex>
</Flex>
@@ -71,7 +71,7 @@ const GridTemplate = (
<IconButton
key={item.name}
onClick={() => onIconSelect({ id: item.name, value: item.name })}
delay={item.delay}
style={{ "--delay": `${item.delay}ms` } as React.CSSProperties}
>
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
{item.initial}
@@ -7,7 +7,6 @@ export const IconButton = styled(NudeButton)<{ delay?: number }>`
width: 32px;
height: 32px;
padding: 4px;
--delay: ${({ delay }) => delay && `${delay}ms`};
&: ${hover} {
background: ${s("listItemHoverBackground")};
+18 -17
View File
@@ -82,6 +82,7 @@ const IconPicker = ({
modal: true,
unstable_offset: [0, 0],
});
const { hide, show, visible } = popover;
const tab = useTabState({ selectedId: defaultTab });
const previouslyVisible = usePrevious(popover.visible);
@@ -96,12 +97,12 @@ const IconPicker = ({
const handleIconChange = React.useCallback(
(ic: string) => {
popover.hide();
hide();
const icType = determineIconType(ic);
const finalColor = icType === IconType.SVG ? chosenColor : null;
onChange(ic, finalColor);
},
[popover, onChange, chosenColor]
[hide, onChange, chosenColor]
);
const handleIconColorChange = React.useCallback(
@@ -118,32 +119,32 @@ const IconPicker = ({
);
const handleIconRemove = React.useCallback(() => {
popover.hide();
hide();
onChange(null, null);
}, [popover, onChange]);
}, [hide, onChange]);
const handlePopoverButtonClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (popover.visible) {
popover.hide();
if (visible) {
hide();
} else {
popover.show();
show();
}
},
[popover]
[hide, show, visible]
);
// Popover open effect
React.useEffect(() => {
if (popover.visible && !previouslyVisible) {
if (visible && !previouslyVisible) {
onOpen?.();
} else if (!popover.visible && previouslyVisible) {
} else if (!visible && previouslyVisible) {
onClose?.();
setQuery("");
resetDefaultTab();
}
}, [popover.visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
}, [visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
@@ -198,7 +199,7 @@ const IconPicker = ({
{...tab}
id={TAB_NAMES["Icon"]}
aria-label={t("Icons")}
active={tab.selectedId === TAB_NAMES["Icon"]}
$active={tab.selectedId === TAB_NAMES["Icon"]}
>
{t("Icons")}
</StyledTab>
@@ -206,7 +207,7 @@ const IconPicker = ({
{...tab}
id={TAB_NAMES["Emoji"]}
aria-label={t("Emojis")}
active={tab.selectedId === TAB_NAMES["Emoji"]}
$active={tab.selectedId === TAB_NAMES["Emoji"]}
>
{t("Emojis")}
</StyledTab>
@@ -273,7 +274,7 @@ const TabActionsWrapper = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
const StyledTab = styled(Tab)<{ active: boolean }>`
const StyledTab = styled(Tab)<{ $active: boolean }>`
position: relative;
font-weight: 500;
font-size: 14px;
@@ -282,15 +283,15 @@ const StyledTab = styled(Tab)<{ active: boolean }>`
border: 0;
padding: 8px 12px;
user-select: none;
color: ${({ active }) => (active ? s("textSecondary") : s("textTertiary"))};
color: ${({ $active }) => ($active ? s("textSecondary") : s("textTertiary"))};
transition: color 100ms ease-in-out;
&: ${hover} {
color: ${s("textSecondary")};
}
${({ active }) =>
active &&
${({ $active }) =>
$active &&
css`
&:after {
content: "";
+3 -3
View File
@@ -10,7 +10,7 @@ import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Button, { Inner } from "~/components/Button";
import Button, { Props as ButtonProps, Inner } from "~/components/Button";
import Text from "~/components/Text";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
@@ -33,7 +33,7 @@ export type Option = {
divider?: boolean;
};
export type Props = {
export type Props = Omit<ButtonProps<any>, "onChange"> & {
id?: string;
name?: string;
value?: string | null;
@@ -313,7 +313,7 @@ const StyledButton = styled(Button)<{ $nude?: boolean }>`
margin-bottom: 16px;
display: block;
width: 100%;
cursor: default;
cursor: var(--pointer);
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
+7 -1
View File
@@ -1,6 +1,8 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { $Diff } from "utility-types";
import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types";
import { EmptySelectValue } from "~/types";
import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect";
@@ -19,7 +21,7 @@ function InputSelectPermission(
const { t } = useTranslation();
return (
<InputSelect
<Select
ref={ref}
label={t("Permission")}
options={[
@@ -45,4 +47,8 @@ function InputSelectPermission(
);
}
const Select = styled(InputSelect)`
color: ${s("textSecondary")};
`;
export default React.forwardRef(InputSelectPermission);
+6 -3
View File
@@ -39,12 +39,15 @@ const LocaleTime: React.FC<Props> = ({
relative,
tooltipDelay,
}: Props) => {
const userLocale: string = useUserLocale() || "";
const dateFormatLong = {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
@@ -24,13 +24,15 @@ import NotificationListItem from "./NotificationListItem";
type Props = {
/** Callback when the notification panel wants to close. */
onRequestClose: () => void;
/** Whether the panel is open or not. */
isOpen: boolean;
};
/**
* A panel containing a list of notifications and controls to manage them.
*/
function Notifications(
{ onRequestClose }: Props,
{ onRequestClose, isOpen }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const context = useActionContext();
@@ -72,7 +74,7 @@ function Notifications(
<PaginatedList
fetch={notifications.fetchPage}
options={{ archived: false }}
items={notifications.orderedData}
items={isOpen ? notifications.orderedData : undefined}
renderItem={(item: Notification) => (
<NotificationListItem
key={item.id}
@@ -40,7 +40,11 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
shrink
flex
>
<Notifications onRequestClose={popover.hide} ref={scrollableRef} />
<Notifications
onRequestClose={popover.hide}
isOpen={popover.visible}
ref={scrollableRef}
/>
</StyledPopover>
</>
);
+2 -1
View File
@@ -19,7 +19,8 @@ export interface PaginatedItem {
}
type Props<T> = WithTranslation &
RootStore & {
RootStore &
React.HTMLAttributes<HTMLDivElement> & {
fetch?: (
options: Record<string, any> | undefined
) => Promise<T[] | undefined> | undefined;
@@ -1,4 +1,3 @@
import invariant from "invariant";
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
@@ -17,7 +16,6 @@ import Input, { NativeInput } from "~/components/Input";
import Switch from "~/components/Switch";
import env from "~/env";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { AvatarSize } from "../../Avatar";
import CopyToClipboard from "../../CopyToClipboard";
import NudeButton from "../../NudeButton";
@@ -39,7 +37,6 @@ type Props = {
};
function PublicAccess({ document, share, sharedParent }: Props) {
const { shares } = useStores();
const { t } = useTranslation();
const theme = useTheme();
const [validationError, setValidationError] = React.useState("");
@@ -55,18 +52,15 @@ function PublicAccess({ document, share, sharedParent }: Props) {
const handlePublishedChange = React.useCallback(
async (event) => {
const share = shares.getByDocumentId(document.id);
invariant(share, "Share must exist");
try {
await share.save({
await share?.save({
published: event.currentTarget.checked,
});
} catch (err) {
toast.error(err.message);
}
},
[document.id, shares]
[share]
);
const handleUrlChange = React.useMemo(
@@ -8,7 +8,6 @@ import { toast } from "sonner";
import { DocumentPermission } from "@shared/types";
import Document from "~/models/Document";
import Group from "~/models/Group";
import Share from "~/models/Share";
import User from "~/models/User";
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
import NudeButton from "~/components/NudeButton";
@@ -32,26 +31,19 @@ import { AccessControlList } from "./AccessControlList";
type Props = {
/** The document to share. */
document: Document;
/** The existing share model, if any. */
share: Share | null | undefined;
/** The existing share parent model, if any. */
sharedParent: Share | null | undefined;
/** Callback fired when the popover requests to be closed. */
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
};
function SharePopover({
document,
share,
sharedParent,
onRequestClose,
visible,
}: Props) {
function SharePopover({ document, onRequestClose, visible }: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(document);
const { shares } = useStores();
const share = shares.getByDocumentId(document.id);
const sharedParent = shares.getByDocumentParents(document.id);
const [hasRendered, setHasRendered] = React.useState(visible);
const { users, userMemberships, groups, groupMemberships } = useStores();
const [query, setQuery] = React.useState("");
+7 -7
View File
@@ -133,16 +133,16 @@ function AppSidebar() {
<Section>
<SharedWithMe />
</Section>
<Section auto>
<Section>
<Collections />
</Section>
{can.createDocument && (
<Section auto>
<ArchiveLink />
</Section>
)}
<Section>
{can.createDocument && (
<>
<ArchiveLink />
<TrashLink />
</>
)}
{can.createDocument && <TrashLink />}
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
+9 -9
View File
@@ -217,15 +217,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
/>
}
>
<NotificationsPopover>
{(rest: SidebarButtonProps) => (
<SidebarButton
{...rest}
position="bottom"
image={<NotificationIcon />}
/>
)}
</NotificationsPopover>
<Notifications />
</SidebarButton>
)}
</AccountMenu>
@@ -240,6 +232,14 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
);
});
const Notifications = () => (
<NotificationsPopover>
{(rest: SidebarButtonProps) => (
<SidebarButton {...rest} position="bottom" image={<NotificationIcon />} />
)}
</NotificationsPopover>
);
const Backdrop = styled.a`
animation: ${fadeIn} 250ms ease-in-out;
position: fixed;
@@ -1,41 +1,101 @@
import isUndefined from "lodash/isUndefined";
import { observer } from "mobx-react";
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Flex from "@shared/components/Flex";
import Collection from "~/models/Collection";
import PaginatedList from "~/components/PaginatedList";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { archivePath } from "~/utils/routeHelpers";
import SidebarLink, { DragObject } from "./SidebarLink";
import { useDropToArchive } from "../hooks/useDragAndDrop";
import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
import { StyledError } from "./Collections";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarLink from "./SidebarLink";
function ArchiveLink() {
const { policies, documents } = useStores();
const { collections } = useStores();
const { t } = useTranslation();
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
accept: "document",
drop: async (item: DragObject) => {
const document = documents.get(item.id);
await document?.archive();
toast.success(t("Document archived"));
},
canDrop: (item) => policies.abilities(item.id).archive,
collect: (monitor) => ({
isDocumentDropping: monitor.isOver(),
}),
});
const [disclosure, setDisclosure] = React.useState<boolean>(false);
const [expanded, setExpanded] = React.useState<boolean | undefined>();
const { request, data, loading, error } = useRequest(
collections.fetchArchived,
true
);
React.useEffect(() => {
if (!isUndefined(data) && !loading && isUndefined(error)) {
setDisclosure(data.length > 0);
}
}, [data, loading, error]);
React.useEffect(() => {
setDisclosure(collections.archived.length > 0);
}, [collections.archived]);
React.useEffect(() => {
if (disclosure && isUndefined(expanded)) {
setExpanded(false);
}
}, [disclosure]);
React.useEffect(() => {
if (expanded) {
void request();
}
}, [expanded, request]);
const handleDisclosureClick = React.useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((e) => !e);
}, []);
const handleClick = React.useCallback(() => {
setExpanded(true);
}, []);
const [{ isOverArchiveSection, isDragging }, dropToArchiveRef] =
useDropToArchive();
return (
<div ref={dropToArchiveDocument}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isDocumentDropping} />}
exact={false}
label={t("Archive")}
active={documents.active?.isArchived && !documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
/>
</Relative>
) : null}
</Flex>
);
}
@@ -0,0 +1,47 @@
import * as React from "react";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import Relative from "./Relative";
type Props = {
collection: Collection;
depth?: number;
};
export function ArchivedCollectionLink({ collection, depth }: Props) {
const { documents } = useStores();
const [expanded, setExpanded] = React.useState(false);
const handleDisclosureClick = React.useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((e) => !e);
}, []);
const handleClick = React.useCallback(() => {
setExpanded(true);
}, []);
return (
<>
<CollectionLink
depth={depth ? depth : 0}
collection={collection}
expanded={expanded}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={expanded}
prefetchDocument={documents.prefetchDocument}
/>
</Relative>
</>
);
}
@@ -4,7 +4,6 @@ import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
@@ -30,6 +29,8 @@ type Props = {
onDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
activeDocument: Document | undefined;
isDraggingAnyCollection?: boolean;
depth?: number;
onClick?: () => void;
};
const CollectionLink: React.FC<Props> = ({
@@ -37,13 +38,14 @@ const CollectionLink: React.FC<Props> = ({
expanded,
onDisclosureClick,
isDraggingAnyCollection,
depth,
onClick,
}: Props) => {
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const can = usePolicy(collection);
const { t } = useTranslation();
const history = useHistory();
const sidebarContext = useSidebarContext();
const editableTitleRef = React.useRef<RefHandle>(null);
@@ -52,9 +54,8 @@ const CollectionLink: React.FC<Props> = ({
await collection.save({
name,
});
history.replace(collection.path, history.location.state);
},
[collection, history]
[collection]
);
// Drop to re-parent document
@@ -111,10 +112,15 @@ const CollectionLink: React.FC<Props> = ({
sidebarContext,
});
const handleRename = React.useCallback(() => {
editableTitleRef.current?.setIsEditing(true);
}, [editableTitleRef]);
return (
<Relative ref={drop}>
<DropToImport collectionId={collection.id}>
<SidebarLink
onClick={onClick}
to={{
pathname: collection.path,
state: { sidebarContext },
@@ -140,7 +146,7 @@ const CollectionLink: React.FC<Props> = ({
/>
}
exact={false}
depth={0}
depth={depth ? depth : 0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
@@ -155,7 +161,7 @@ const CollectionLink: React.FC<Props> = ({
</NudeButton>
<CollectionMenu
collection={collection}
onRename={() => editableTitleRef.current?.setIsEditing(true)}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
@@ -55,7 +55,7 @@ function Collections() {
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.orderedData}
items={collections.allActive}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
@@ -84,7 +84,7 @@ function Collections() {
);
}
const StyledError = styled(Error)`
export const StyledError = styled(Error)`
font-size: 15px;
padding: 0 8px;
`;
@@ -41,16 +41,6 @@ function EditableTitle(
setIsEditing(true);
}, []);
const handleKeyDown = React.useCallback(
(event) => {
if (event.key === "Escape") {
setIsEditing(false);
setValue(originalValue);
}
},
[originalValue]
);
const stopPropagation = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
@@ -63,6 +53,7 @@ function EditableTitle(
const handleSave = React.useCallback(
async (ev) => {
ev.preventDefault();
ev.stopPropagation();
setIsEditing(false);
const trimmedValue = value.trim();
@@ -85,6 +76,22 @@ function EditableTitle(
[originalValue, value, onSubmit]
);
const handleKeyDown = React.useCallback(
async (ev) => {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "Escape") {
setIsEditing(false);
setValue(originalValue);
}
if (ev.key === "Enter") {
await handleSave(ev);
}
},
[handleSave, originalValue]
);
React.useEffect(() => {
onEditing?.(isEditing);
}, [onEditing, isEditing]);
+18 -30
View File
@@ -2,41 +2,29 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Badge from "~/components/Badge";
import { version } from "../../../../package.json";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import { version as currentVersion } from "../../../../package.json";
import SidebarLink from "./SidebarLink";
export default function Version() {
const [releasesBehind, setReleasesBehind] = React.useState(-1);
const [versionsBehind, setVersionsBehind] = React.useState(-1);
const { t } = useTranslation();
React.useEffect(() => {
async function loadReleases() {
const res = await fetch(
"https://api.github.com/repos/outline/outline/releases"
);
const releases = await res.json();
if (Array.isArray(releases)) {
const everyNewRelease = releases
.map((release) => release.tag_name)
.findIndex((tagName) => tagName === `v${version}`);
const onlyFullNewRelease = releases
.filter((release) => !release.prerelease)
.map((release) => release.tag_name)
.findIndex((tagName) => tagName === `v${version}`);
const computedReleasesBehind = version.includes("pre")
? everyNewRelease
: onlyFullNewRelease;
if (computedReleasesBehind >= 0) {
setReleasesBehind(computedReleasesBehind);
async function loadVersionInfo() {
try {
// Fetch version info from the server-side proxy
const res = await client.post("/installation.info");
if (res.data && res.data.versionsBehind >= 0) {
setVersionsBehind(res.data.versionsBehind);
}
} catch (error) {
Logger.error("Failed to load version info", error);
}
}
void loadReleases();
void loadVersionInfo();
}, []);
return (
@@ -45,16 +33,16 @@ export default function Version() {
href="https://github.com/outline/outline/releases"
label={
<>
v{version}
{releasesBehind >= 0 && (
v{currentVersion}
{versionsBehind >= 0 && (
<>
<br />
<LilBadge>
{releasesBehind === 0
{versionsBehind === 0
? t("Up to date")
: t(`{{ releasesBehind }} versions behind`, {
releasesBehind,
count: releasesBehind,
releasesBehind: versionsBehind,
count: versionsBehind,
})}
</LilBadge>
</>
@@ -149,6 +149,7 @@ export function useDragDocument(
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
} as DragObject),
canDrag: () => !!document?.isActive,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
@@ -245,6 +246,7 @@ export function useDropToReparentDocument(
!!pathToNode &&
!pathToNode.includes(monitor.getItem().id) &&
item.id !== node.id &&
!!document?.isActive &&
policies.abilities(node.id).update &&
policies.abilities(item.id).move,
hover: (_item, monitor) => {
@@ -297,6 +299,8 @@ export function useDropToReorderDocument(
const { t } = useTranslation();
const { documents, collections, dialogs, policies } = useStores();
const document = documents.get(node.id);
return useDrop<
DragObject,
Promise<void>,
@@ -304,7 +308,11 @@ export function useDropToReorderDocument(
>({
accept: "document",
canDrop: (item: DragObject) => {
if (item.id === node.id || !policies.abilities(item.id)?.move) {
if (
item.id === node.id ||
!policies.abilities(item.id)?.move ||
!document?.isActive
) {
return false;
}
@@ -427,3 +435,44 @@ export function useDropToReorderUserMembership(getIndex?: () => string) {
}),
});
}
/**
* Hook for shared logic that allows dropping documents and collections onto archive section
*/
export function useDropToArchive() {
const accept = ["document", "collection"];
const { documents, collections, policies } = useStores();
const { t } = useTranslation();
return useDrop<
DragObject,
Promise<void>,
{ isOverArchiveSection: boolean; isDragging: boolean }
>({
accept,
drop: async (item, monitor) => {
const type = monitor.getItemType();
let model;
if (type === "collection") {
model = collections.get(item.id);
} else {
model = documents.get(item.id);
}
if (model) {
await model.archive();
toast.success(
type === "collection"
? t("Collection archived")
: t("Document archived")
);
}
},
canDrop: (item) => policies.abilities(item.id).archive,
collect: (monitor) => ({
isOverArchiveSection: !!monitor.isOver(),
isDragging: monitor.canDrop(),
}),
});
}
+107 -49
View File
@@ -25,7 +25,7 @@ import User from "~/models/User";
import UserMembership from "~/models/UserMembership";
import withStores from "~/components/withStores";
import {
PartialWithId,
PartialExcept,
WebsocketCollectionUpdateIndexEvent,
WebsocketEntitiesEvent,
WebsocketEntityDeletedEvent,
@@ -214,23 +214,20 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.update",
action(
(event: PartialWithId<Document> & { title: string; url: string }) => {
documents.add(event);
action((event: PartialExcept<Document, "id" | "title" | "url">) => {
documents.add(event);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.updateDocument(event);
}
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.updateDocument(event);
}
)
})
);
this.socket.on(
"documents.archive",
action((event: PartialWithId<Document>) => {
documents.add(event);
policies.remove(event.id);
action((event: PartialExcept<Document, "id">) => {
documents.addToArchive(event as Document);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
@@ -241,7 +238,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.delete",
action((event: PartialWithId<Document>) => {
action((event: PartialExcept<Document, "id">) => {
documents.add(event);
policies.remove(event.id);
@@ -265,7 +262,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.add_user",
async (event: PartialWithId<UserMembership>) => {
async (event: PartialExcept<UserMembership, "id">) => {
userMemberships.add(event);
// Any existing child policies are now invalid
@@ -286,7 +283,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.remove_user",
(event: PartialWithId<UserMembership>) => {
(event: PartialExcept<UserMembership, "id">) => {
userMemberships.remove(event.id);
// Any existing child policies are now invalid
@@ -308,7 +305,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.add_group",
(event: PartialWithId<GroupMembership>) => {
(event: PartialExcept<GroupMembership, "id">) => {
groupMemberships.add(event);
const group = groups.get(event.groupId!);
@@ -330,16 +327,23 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.remove_group",
(event: PartialWithId<GroupMembership>) => {
(event: PartialExcept<GroupMembership, "id">) => {
groupMemberships.remove(event.id);
}
);
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
this.socket.on("comments.create", (event: PartialExcept<Comment, "id">) => {
comments.add(event);
});
this.socket.on("comments.update", (event: PartialWithId<Comment>) => {
this.socket.on("comments.update", (event: PartialExcept<Comment, "id">) => {
const comment = comments.get(event.id);
// Existing policy becomes invalid when the resolution status has changed and we don't have the latest version.
if (comment?.resolvedAt !== event.resolvedAt) {
policies.remove(event.id);
}
comments.add(event);
});
@@ -347,11 +351,11 @@ class WebsocketProvider extends React.Component<Props> {
comments.remove(event.modelId);
});
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
this.socket.on("groups.create", (event: PartialExcept<Group, "id">) => {
groups.add(event);
});
this.socket.on("groups.update", (event: PartialWithId<Group>) => {
this.socket.on("groups.update", (event: PartialExcept<Group, "id">) => {
groups.add(event);
});
@@ -359,24 +363,36 @@ class WebsocketProvider extends React.Component<Props> {
groups.remove(event.modelId);
});
this.socket.on("groups.add_user", (event: PartialWithId<GroupUser>) => {
groupUsers.add(event);
});
this.socket.on(
"groups.add_user",
(event: PartialExcept<GroupUser, "id">) => {
groupUsers.add(event);
}
);
this.socket.on("groups.remove_user", (event: PartialWithId<GroupUser>) => {
groupUsers.removeAll({
groupId: event.groupId,
userId: event.userId,
});
});
this.socket.on(
"groups.remove_user",
(event: PartialExcept<GroupUser, "id">) => {
groupUsers.removeAll({
groupId: event.groupId,
userId: event.userId,
});
}
);
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {
collections.add(event);
});
this.socket.on(
"collections.create",
(event: PartialExcept<Collection, "id">) => {
collections.add(event);
}
);
this.socket.on("collections.update", (event: PartialWithId<Collection>) => {
collections.add(event);
});
this.socket.on(
"collections.update",
(event: PartialExcept<Collection, "id">) => {
collections.add(event);
}
);
this.socket.on(
"collections.delete",
@@ -398,7 +414,49 @@ class WebsocketProvider extends React.Component<Props> {
})
);
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
this.socket.on(
"collections.archive",
async (event: PartialExcept<Collection, "id">) => {
const collectionId = event.id;
// Fetch collection to update policies
await collections.fetch(collectionId, { force: true });
documents.unarchivedInCollection(collectionId).forEach(
action((doc) => {
if (!doc.publishedAt) {
// draft is to be detached from collection, not archived
doc.collectionId = null;
} else {
doc.archivedAt = event.archivedAt as string;
}
policies.remove(doc.id);
})
);
}
);
this.socket.on(
"collections.restore",
async (event: PartialExcept<Collection, "id">) => {
const collectionId = event.id;
documents
.archivedInCollection(collectionId, {
archivedAt: event.archivedAt as string,
})
.forEach(
action((doc) => {
doc.archivedAt = null;
policies.remove(doc.id);
})
);
// Fetch collection to update policies
await collections.fetch(collectionId, { force: true });
}
);
this.socket.on("teams.update", (event: PartialExcept<Team, "id">) => {
if ("sharing" in event && event.sharing !== auth.team?.sharing) {
documents.all.forEach((document) => {
policies.remove(document.id);
@@ -410,23 +468,23 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"notifications.create",
(event: PartialWithId<Notification>) => {
(event: PartialExcept<Notification, "id">) => {
notifications.add(event);
}
);
this.socket.on(
"notifications.update",
(event: PartialWithId<Notification>) => {
(event: PartialExcept<Notification, "id">) => {
notifications.add(event);
}
);
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
this.socket.on("pins.create", (event: PartialExcept<Pin, "id">) => {
pins.add(event);
});
this.socket.on("pins.update", (event: PartialWithId<Pin>) => {
this.socket.on("pins.update", (event: PartialExcept<Pin, "id">) => {
pins.add(event);
});
@@ -434,11 +492,11 @@ class WebsocketProvider extends React.Component<Props> {
pins.remove(event.modelId);
});
this.socket.on("stars.create", (event: PartialWithId<Star>) => {
this.socket.on("stars.create", (event: PartialExcept<Star, "id">) => {
stars.add(event);
});
this.socket.on("stars.update", (event: PartialWithId<Star>) => {
this.socket.on("stars.update", (event: PartialExcept<Star, "id">) => {
stars.add(event);
});
@@ -496,14 +554,14 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"fileOperations.create",
(event: PartialWithId<FileOperation>) => {
(event: PartialExcept<FileOperation, "id">) => {
fileOperations.add(event);
}
);
this.socket.on(
"fileOperations.update",
(event: PartialWithId<FileOperation>) => {
(event: PartialExcept<FileOperation, "id">) => {
fileOperations.add(event);
if (
@@ -520,7 +578,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"subscriptions.create",
(event: PartialWithId<Subscription>) => {
(event: PartialExcept<Subscription, "id">) => {
subscriptions.add(event);
}
);
@@ -532,11 +590,11 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("users.update", (event: PartialWithId<User>) => {
this.socket.on("users.update", (event: PartialExcept<User, "id">) => {
users.add(event);
});
this.socket.on("users.demote", async (event: PartialWithId<User>) => {
this.socket.on("users.demote", async (event: PartialExcept<User, "id">) => {
if (event.id === auth.user?.id) {
documents.all.forEach((document) => policies.remove(document.id));
await collections.fetchAll();
@@ -545,7 +603,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"userMemberships.update",
async (event: PartialWithId<UserMembership>) => {
async (event: PartialExcept<UserMembership, "id">) => {
userMemberships.add(event);
}
);
+47 -45
View File
@@ -1,29 +1,51 @@
import { Node as ProsemirrorNode } from "prosemirror-model";
import { EditorView, Decoration } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import { ThemeProvider } from "styled-components";
import { FunctionComponent } from "react";
import Extension from "@shared/editor/lib/Extension";
import { ComponentProps } from "@shared/editor/types";
import { Editor } from "~/editor";
import { NodeViewRenderer } from "./NodeViewRenderer";
type Component = (props: ComponentProps) => React.ReactElement;
type ComponentViewConstructor = {
/** The editor instance. */
editor: Editor;
/** The extension the view belongs to. */
extension: Extension;
/** The node that the view is responsible for. */
node: ProsemirrorNode;
/** The editor view instance. */
view: EditorView;
/** A function that returns the current position of the node. */
getPos: () => number;
/** The decorations applied to the node. */
decorations: Decoration[];
};
export default class ComponentView {
component: Component;
/** The React component to render. */
component: FunctionComponent<ComponentProps>;
/** The editor instance. */
editor: Editor;
/** The extension the view belongs to. */
extension: Extension;
/** The node that the view is responsible for. */
node: ProsemirrorNode;
/** The editor view instance. */
view: EditorView;
/** A function that returns the current position of the node. */
getPos: () => number;
/** The decorations applied to the node. */
decorations: Decoration[];
/** The renderer instance. */
renderer: NodeViewRenderer<ComponentProps>;
/** Whether the node is selected. */
isSelected = false;
/** The DOM element that the node is rendered into. */
dom: HTMLElement | null;
// See https://prosemirror.net/docs/ref/#view.NodeView
constructor(
component: Component,
component: FunctionComponent<ComponentProps>,
{
editor,
extension,
@@ -31,14 +53,7 @@ export default class ComponentView {
view,
getPos,
decorations,
}: {
editor: Editor;
extension: Extension;
node: ProsemirrorNode;
view: EditorView;
getPos: () => number;
decorations: Decoration[];
}
}: ComponentViewConstructor
) {
this.component = component;
this.editor = editor;
@@ -52,51 +67,33 @@ export default class ComponentView {
: document.createElement("div");
this.dom.classList.add(`component-${node.type.name}`);
this.renderer = new NodeViewRenderer(this.dom, this.component, this.props);
this.renderElement();
window.addEventListener("theme-changed", this.renderElement);
window.addEventListener("location-changed", this.renderElement);
// Add the renderer to the editor's set of renderers so that it is included in the React tree.
this.editor.renderers.add(this.renderer);
}
renderElement = () => {
const { theme } = this.editor.props;
const children = this.component({
theme,
node: this.node,
view: this.view,
isSelected: this.isSelected,
isEditable: this.view.editable,
getPos: this.getPos,
});
ReactDOM.render(
<ThemeProvider theme={theme}>{children}</ThemeProvider>,
this.dom
);
};
update(node: ProsemirrorNode) {
if (node.type !== this.node.type) {
return false;
}
this.node = node;
this.renderElement();
this.renderer.updateProps(this.props);
return true;
}
selectNode() {
if (this.view.editable) {
this.isSelected = true;
this.renderElement();
this.renderer.updateProps(this.props);
}
}
deselectNode() {
if (this.view.editable) {
this.isSelected = false;
this.renderElement();
this.renderer.updateProps(this.props);
}
}
@@ -105,16 +102,21 @@ export default class ComponentView {
}
destroy() {
window.removeEventListener("theme-changed", this.renderElement);
window.removeEventListener("location-changed", this.renderElement);
if (this.dom) {
ReactDOM.unmountComponentAtNode(this.dom);
}
this.editor.renderers.delete(this.renderer);
this.dom = null;
}
ignoreMutation() {
return true;
}
get props() {
return {
node: this.node,
view: this.view,
isSelected: this.isSelected,
isEditable: this.view.editable,
getPos: this.getPos,
} as ComponentProps;
}
}
+1
View File
@@ -29,6 +29,7 @@ const EmojiMenu = (props: Props) => {
.map((item) => {
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
// @ts-expect-error emojiMartToGemoji key
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.value;
+48 -46
View File
@@ -130,59 +130,61 @@ function usePosition({
// Images need their own positioning to get the toolbar in the center
if (isImageSelection) {
const element = view.nodeDOM(selection.from);
const element = view.nodeDOM(selection.from) as HTMLElement;
// Images are wrapped which impacts positioning - need to get the element
// specifically tagged as the handle
const imageElement = (element as HTMLElement).getElementsByClassName(
const imageElement = element.getElementsByClassName(
EditorStyleHelper.imageHandle
)[0];
const { left, top, width } = imageElement.getBoundingClientRect();
if (imageElement) {
const { left, top, width } = imageElement.getBoundingClientRect();
return {
left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left),
top: Math.round(top - menuHeight - offsetParent.top),
offset: 0,
visible: true,
};
} else {
// calculate the horizontal center of the selection
const halfSelection =
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
const centerOfSelection = selectionBounds.left + halfSelection;
// position the menu so that it is centered over the selection except in
// the cases where it would extend off the edge of the screen. In these
// instances leave a margin
const margin = 12;
const left = Math.min(
Math.min(
offsetParent.x + offsetParent.width - menuWidth - margin,
window.innerWidth - margin
),
Math.max(
Math.max(offsetParent.x, margin),
centerOfSelection - menuWidth / 2
)
);
const top = Math.min(
window.innerHeight - menuHeight - margin,
Math.max(margin, selectionBounds.top - menuHeight)
);
// if the menu has been offset to not extend offscreen then we should adjust
// the position of the triangle underneath to correctly point to the center
// of the selection still
const offset = left - (centerOfSelection - menuWidth / 2);
return {
left: Math.round(left - offsetParent.left),
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
blockSelection: codeBlock || isColSelection || isRowSelection,
visible: true,
};
return {
left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left),
top: Math.round(top - menuHeight - offsetParent.top),
offset: 0,
visible: true,
};
}
}
// calculate the horizontal center of the selection
const halfSelection =
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
const centerOfSelection = selectionBounds.left + halfSelection;
// position the menu so that it is centered over the selection except in
// the cases where it would extend off the edge of the screen. In these
// instances leave a margin
const margin = 12;
const left = Math.min(
Math.min(
offsetParent.x + offsetParent.width - menuWidth - margin,
window.innerWidth - margin
),
Math.max(
Math.max(offsetParent.x, margin),
centerOfSelection - menuWidth / 2
)
);
const top = Math.min(
window.innerHeight - menuHeight - margin,
Math.max(margin, selectionBounds.top - menuHeight)
);
// if the menu has been offset to not extend offscreen then we should adjust
// the position of the triangle underneath to correctly point to the center
// of the selection still
const offset = left - (centerOfSelection - menuWidth / 2);
return {
left: Math.round(left - offsetParent.left),
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
blockSelection: codeBlock || isColSelection || isRowSelection,
visible: true,
};
}
const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
+1 -1
View File
@@ -92,7 +92,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const user = users.get(item.attrs.modelId);
toast.message(
t(
"{{ userName }} won't by notified as they do not have access to this document",
"{{ userName }} won't be notified, as they do not have access to this document",
{
userName: item.attrs.label,
}
@@ -0,0 +1,28 @@
import isEqual from "lodash/isEqual";
import { action, computed, observable } from "mobx";
import React, { FunctionComponent } from "react";
import { createPortal } from "react-dom";
export class NodeViewRenderer<T extends object> {
@observable public props: T;
public constructor(
public element: HTMLElement,
private Component: FunctionComponent,
props: T
) {
this.props = props;
}
@computed
public get content() {
return createPortal(<this.Component {...this.props} />, this.element);
}
@action
public updateProps(props: T) {
if (!isEqual(props, this.props)) {
this.props = props;
}
}
}
+5 -4
View File
@@ -1,3 +1,4 @@
import deburr from "lodash/deburr";
import escapeRegExp from "lodash/escapeRegExp";
import { observable } from "mobx";
import { Node } from "prosemirror-model";
@@ -243,11 +244,11 @@ export default class FindAndReplaceExtension extends Extension {
});
mergedTextNodes.forEach(({ text = "", pos }) => {
const search = this.findRegExp;
let m;
try {
while ((m = search.exec(text))) {
let m;
const search = this.findRegExp;
while ((m = search.exec(deburr(text)))) {
if (m[0] === "") {
break;
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default class MentionMenuExtension extends Suggestion {
get defaultOptions() {
return {
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u,
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d\s{1}@\.]+)?$/u,
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s{2})|(\s+[\p{L}\p{M}\d]+))$/u,
};
}
+65 -3
View File
@@ -1,13 +1,23 @@
import isEqual from "lodash/isEqual";
import { keymap } from "prosemirror-keymap";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "@getoutline/y-prosemirror";
import { keymap } from "prosemirror-keymap";
} from "y-prosemirror";
import * as Y from "yjs";
import Extension from "@shared/editor/lib/Extension";
import { Second } from "@shared/utils/time";
type UserAwareness = {
user?: {
id: string;
};
anchor: object;
head: object;
};
export default class Multiplayer extends Extension {
get name() {
@@ -18,6 +28,7 @@ export default class Multiplayer extends Extension {
const { user, provider, document: doc } = this.options;
const type = doc.get("default", Y.XmlFragment);
// Assign a user to a client ID once they've made a change and then remove the listener
const assignUser = (tr: Y.Transaction) => {
const clientIds = Array.from(doc.store.clients.keys());
@@ -32,6 +43,54 @@ export default class Multiplayer extends Extension {
}
};
const userAwarenessCache = new Map<
string,
{ aw: UserAwareness; changedAt: Date }
>();
// The opacity of a remote user's selection.
const selectionOpacity = 70;
// The time in milliseconds after which a remote user's selection will be hidden.
const selectionTimeout = 10 * Second.ms;
// We're hijacking this method to store the last time a user's awareness changed as a side
// effect, and otherwise behaving as the default.
const awarenessStateFilter = (
currentClientId: number,
userClientId: number,
aw: UserAwareness
) => {
if (currentClientId === userClientId) {
return false;
}
const userId = aw.user?.id;
const cached = userId ? userAwarenessCache.get(userId) : undefined;
if (!cached || !isEqual(cached?.aw, aw)) {
if (userId) {
userAwarenessCache.set(userId, { aw, changedAt: new Date() });
}
}
return true;
};
// Override the default selection builder to add a background color to the selection
// only if the user's awareness has changed recently this stops selections from lingering.
const selectionBuilder = (u: { id: string; color: string }) => {
const cached = userAwarenessCache.get(u.id);
const opacity =
!cached || cached?.changedAt > new Date(Date.now() - selectionTimeout)
? selectionOpacity
: 0;
return {
style: `background-color: ${u.color}${opacity}`,
class: "ProseMirror-yjs-selection",
};
};
provider.setAwarenessField("user", user);
// only once an actual change has been made do we add the userId <> clientId
@@ -40,7 +99,10 @@ export default class Multiplayer extends Extension {
return [
ySyncPlugin(type),
yCursorPlugin(provider.awareness),
yCursorPlugin(provider.awareness, {
awarenessStateFilter,
selectionBuilder,
}),
yUndoPlugin(),
keymap({
"Mod-z": undo,
+28 -1
View File
@@ -38,7 +38,7 @@ import Mark from "@shared/editor/marks/Mark";
import { basicExtensions as extensions } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import { EventType } from "@shared/editor/types";
import { ComponentProps, EventType } from "@shared/editor/types";
import { ProsemirrorData, UserPreferences } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import EventEmitter from "@shared/utils/events";
@@ -50,6 +50,7 @@ import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
@@ -90,6 +91,10 @@ export type Props = {
scrollTo?: string;
/** Callback for handling uploaded images, should return the url of uploaded file */
uploadFile?: (file: File) => Promise<string>;
/** Callback when prosemirror nodes are initialized on document mount. */
onInit?: () => void;
/** Callback when prosemirror nodes are destroyed on document unmount. */
onDestroy?: () => void;
/** Callback when editor is blurred, as native input */
onBlur?: () => void;
/** Callback when editor is focused, as native input */
@@ -175,6 +180,7 @@ export class Editor extends React.PureComponent<
linkToolbarOpen: false,
};
isInitialized = false;
isBlurred = true;
extensions: ExtensionManager;
elementRef = React.createRef<HTMLDivElement>();
@@ -192,6 +198,7 @@ export class Editor extends React.PureComponent<
};
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
renderers: Set<NodeViewRenderer<ComponentProps>> = new Set();
nodes: { [name: string]: NodeSpec };
marks: { [name: string]: MarkSpec };
commands: Record<string, CommandFactory>;
@@ -281,6 +288,7 @@ export class Editor extends React.PureComponent<
window.removeEventListener("theme-changed", this.dispatchThemeChanged);
this.view?.destroy();
this.mutationObserver?.disconnect();
this.handleEditorDestroy();
}
private init() {
@@ -480,6 +488,8 @@ export class Editor extends React.PureComponent<
self.handleChange();
}
self.handleEditorInit();
self.calculateDir();
// Because Prosemirror and React are not linked we must tell React that
@@ -738,6 +748,22 @@ export class Editor extends React.PureComponent<
);
};
private handleEditorInit = () => {
if (!this.props.onInit || this.isInitialized) {
return;
}
this.props.onInit();
this.isInitialized = true;
};
private handleEditorDestroy = () => {
if (!this.props.onDestroy) {
return;
}
this.props.onDestroy();
};
private handleEditorBlur = () => {
this.setState({ isEditorFocused: false });
return false;
@@ -838,6 +864,7 @@ export class Editor extends React.PureComponent<
Object.values(this.widgets).map((Widget, index) => (
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
))}
{Array.from(this.renderers).map((view) => view.content)}
</Flex>
</EditorContext.Provider>
</PortalContext.Provider>
+5 -1
View File
@@ -14,7 +14,10 @@ export default function codeMenuItems(
): MenuItem[] {
const node = state.selection.$from.node();
const allLanguages = Object.entries(LANGUAGES);
const allLanguages = Object.entries(LANGUAGES) as [
keyof typeof LANGUAGES,
string
][];
const frequentLanguages = getFrequentCodeLanguages();
const frequentLangMenuItems = frequentLanguages.map((value) => {
@@ -49,6 +52,7 @@ export default function codeMenuItems(
visible: !readOnly,
name: "code_block",
icon: <ExpandedIcon />,
// @ts-expect-error We have a fallback for incorrect mapping
label: LANGUAGES[node.attrs.language ?? "none"],
children: languageMenuItems,
},
+11 -1
View File
@@ -1,4 +1,4 @@
import { AlignFullWidthIcon, TrashIcon } from "outline-icons";
import { AlignFullWidthIcon, DownloadIcon, TrashIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
@@ -32,5 +32,15 @@ export default function tableMenuItems(
tooltip: dictionary.deleteTable,
icon: <TrashIcon />,
},
{
name: "separator",
},
{
name: "exportTable",
tooltip: dictionary.exportAsCSV,
label: "CSV",
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
icon: <DownloadIcon />,
},
];
}
+1
View File
@@ -77,6 +77,7 @@ export default function useDictionary() {
sortAsc: t("Sort ascending"),
sortDesc: t("Sort descending"),
table: t("Table"),
exportAsCSV: t("Export as CSV"),
toggleHeader: t("Toggle header"),
mathInline: t("Math inline (LaTeX)"),
mathBlock: t("Math block (LaTeX)"),
+2 -9
View File
@@ -3,16 +3,9 @@ import useCurrentUser from "./useCurrentUser";
/**
* Returns the user's locale, or undefined if the user is not logged in.
*
* @param languageCode Whether to only return the language code
* @returns The user's locale, or undefined if the user is not logged in
*/
export default function useUserLocale(languageCode?: boolean) {
export default function useUserLocale() {
const user = useCurrentUser({ rejectOnEmpty: false });
if (!user?.language) {
return undefined;
}
const { language } = user;
return languageCode ? language.split("_")[0] : language;
return user?.language;
}
-6
View File
@@ -32,12 +32,6 @@ void PluginManager.loadPlugins();
initI18n(env.DEFAULT_LANGUAGE);
const element = window.document.getElementById("root");
history.listen(() => {
requestAnimationFrame(() =>
window.dispatchEvent(new Event("location-changed"))
);
});
if (env.SENTRY_DSN) {
initSentry(history);
}
+4
View File
@@ -29,6 +29,8 @@ import {
unstarCollection,
searchInCollection,
createTemplate,
archiveCollection,
restoreCollection,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -151,6 +153,7 @@ function CollectionMenu({
const canUserInTeam = usePolicy(team);
const items: MenuItem[] = React.useMemo(
() => [
actionToMenuItem(restoreCollection, context),
actionToMenuItem(starCollection, context),
actionToMenuItem(unstarCollection, context),
{
@@ -224,6 +227,7 @@ function CollectionMenu({
onClick: handleExport,
icon: <ExportIcon />,
},
actionToMenuItem(archiveCollection, context),
actionToMenuItem(searchInCollection, context),
{
type: "separator",
+1 -1
View File
@@ -75,7 +75,7 @@ function CommentMenu({
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: onEdit,
visible: can.update,
visible: can.update && !comment.isResolved,
},
actionToMenuItem(
resolveCommentFactory({
+4 -5
View File
@@ -215,8 +215,8 @@ const MenuContent: React.FC<MenuContentProps> = ({
type: "button",
title: t("Restore"),
visible:
((document.isWorkspaceTemplate || !!collection) && can.restore) ||
!!can.unarchive,
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive),
onClick: (ev) => handleRestore(ev),
icon: <RestoreIcon />,
},
@@ -224,9 +224,8 @@ const MenuContent: React.FC<MenuContentProps> = ({
type: "submenu",
title: t("Restore"),
visible:
!document.isWorkspaceTemplate &&
!collection &&
!!can.restore &&
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive) &&
restoreItems.length !== 0,
style: {
left: -170,
+4 -1
View File
@@ -16,10 +16,13 @@ class ApiKey extends ParanoidModel {
@observable
expiresAt?: string;
/** An optional datetime that the API key was last used at. */
/** Timestamp that the API key was last used. */
@observable
lastActiveAt?: string;
/** The user ID that the API key belongs to. */
userId: string;
/** The plain text value of the API key, only available on creation. */
value: string;
+31
View File
@@ -80,6 +80,18 @@ export default class Collection extends ParanoidModel {
@observable
urlId: string;
/**
* The date and time the collection was archived.
*/
@observable
archivedAt: string;
/**
* User who archived the collection.
*/
@observable
archivedBy?: User;
/** Returns whether the collection is empty, or undefined if not loaded. */
@computed
get isEmpty(): boolean | undefined {
@@ -154,6 +166,21 @@ export default class Collection extends ParanoidModel {
.filter(Boolean);
}
@computed
get isArchived() {
return !!this.archivedAt;
}
@computed
get isDeleted() {
return !!this.deletedAt;
}
@computed
get isActive() {
return !this.isArchived && !this.isDeleted;
}
fetchDocuments = async (options?: { force: boolean }) => {
if (this.isFetching) {
return;
@@ -314,6 +341,10 @@ export default class Collection extends ParanoidModel {
@action
unstar = async () => this.store.unstar(this);
archive = () => this.store.archive(this);
restore = () => this.store.restore(this);
export = (format: FileOperationFormat, includeAttachments: boolean) =>
client.post("/collections.export", {
id: this.id,
+3 -3
View File
@@ -26,7 +26,7 @@ class Comment extends Model {
* The Prosemirror data representing the comment content
*/
@Field
@observable
@observable.shallow
data: ProsemirrorData;
/**
@@ -99,8 +99,8 @@ class Comment extends Model {
* Whether the comment is resolved
*/
@computed
public get isResolved() {
return !!this.resolvedAt;
public get isResolved(): boolean {
return !!this.resolvedAt || !!this.parentComment?.isResolved;
}
/**
+9 -7
View File
@@ -28,7 +28,7 @@ import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import Notification from "./Notification";
import View from "./View";
import ParanoidModel from "./base/ParanoidModel";
import ArchivableModel from "./base/ArchivableModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
@@ -38,7 +38,7 @@ type SaveOptions = JSONObject & {
autosave?: boolean;
};
export default class Document extends ParanoidModel {
export default class Document extends ArchivableModel {
static modelName = "Document";
constructor(fields: Record<string, any>, store: DocumentsStore) {
@@ -176,7 +176,10 @@ export default class Document extends ParanoidModel {
@observable
parentDocumentId: string | undefined;
@Relation(() => Document)
/**
* Parent document that this is a child of, if any.
*/
@Relation(() => Document, { onArchive: "cascade" })
parentDocument?: Document;
@observable
@@ -191,9 +194,6 @@ export default class Document extends ParanoidModel {
@observable
publishedAt: string | undefined;
@observable
archivedAt: string;
/**
* @deprecated Use path instead
*/
@@ -643,7 +643,9 @@ export default class Document extends ParanoidModel {
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const markdown = serializer.serialize(Node.fromJSON(schema, this.data));
const markdown = serializer.serialize(Node.fromJSON(schema, this.data), {
softBreak: true,
});
return markdown;
};
+6 -2
View File
@@ -1,5 +1,9 @@
import { computed, observable } from "mobx";
import { FileOperationFormat, FileOperationType } from "@shared/types";
import {
FileOperationFormat,
FileOperationState,
FileOperationType,
} from "@shared/types";
import { bytesToHumanReadable } from "@shared/utils/files";
import User from "./User";
import Model from "./base/Model";
@@ -10,7 +14,7 @@ class FileOperation extends Model {
id: string;
@observable
state: string;
state: FileOperationState;
name: string;
+2 -1
View File
@@ -11,6 +11,7 @@ import {
UserRole,
} from "@shared/types";
import type { NotificationSettings } from "@shared/types";
import { locales } from "@shared/utils/date";
import { client } from "~/utils/ApiClient";
import Document from "./Document";
import Group from "./Group";
@@ -39,7 +40,7 @@ class User extends ParanoidModel {
@Field
@observable
language: string;
language: keyof typeof locales;
@Field
@observable
+7
View File
@@ -0,0 +1,7 @@
import { observable } from "mobx";
import ParanoidModel from "./ParanoidModel";
export default abstract class ArchivableModel extends ParanoidModel {
@observable
archivedAt: string | null;
}
+3 -1
View File
@@ -40,6 +40,7 @@ export default abstract class Model {
* @returns A promise that resolves when loading is complete.
*/
async loadRelations(
this: Model,
options: { withoutPolicies?: boolean } = {}
): Promise<any> {
const relations = getRelationsForModelClass(
@@ -62,7 +63,7 @@ export default abstract class Model {
if ("fetch" in store) {
const id = this[properties.idKey];
if (id) {
promises.push(store.fetch(id));
promises.push(store.fetch(id as string));
}
}
}
@@ -145,6 +146,7 @@ export default abstract class Model {
if (key === "initialized") {
continue;
}
// @ts-expect-error TODO
this[key] = data[key];
} catch (error) {
Logger.warn(`Error setting ${key} on model`, error);
+6 -2
View File
@@ -3,17 +3,21 @@ import type Model from "../base/Model";
/** The behavior of a relationship on deletion */
type DeleteBehavior = "cascade" | "null" | "ignore";
/** The behavior of a relationship on archival */
type ArchiveBehavior = "cascade" | "null" | "ignore";
type RelationOptions<T = Model> = {
/** Whether this relation is required. */
required?: boolean;
/** Behavior of this model when relationship is deleted. */
onDelete: DeleteBehavior | ((item: T) => DeleteBehavior);
onDelete?: DeleteBehavior | ((item: T) => DeleteBehavior);
/** Behavior of this model when relationship is archived. */
onArchive?: ArchiveBehavior | ((item: T) => ArchiveBehavior);
};
type RelationProperties<T = Model> = {
/** The name of the property on the model that stores the ID of the relation. */
idKey: string;
idKey: keyof T;
/** A function that returns the class of the relation. */
relationClassResolver: () => typeof Model;
/** Options for the relation. */
@@ -43,8 +43,12 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
memberships.fetchPage(options),
groupMemberships.fetchPage(options),
]);
setUsersCount(users[PAGINATION_SYMBOL].total);
setGroupsCount(groups[PAGINATION_SYMBOL].total);
if (users[PAGINATION_SYMBOL]) {
setUsersCount(users[PAGINATION_SYMBOL].total);
}
if (groups[PAGINATION_SYMBOL]) {
setGroupsCount(groups[PAGINATION_SYMBOL].total);
}
} finally {
setIsLoading(false);
}
@@ -0,0 +1,29 @@
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import ErrorBoundary from "~/components/ErrorBoundary";
import Notice from "~/components/Notice";
import Time from "~/components/Time";
type Props = {
collection: Collection;
};
export default function Notices({ collection }: Props) {
const { t } = useTranslation();
return (
<ErrorBoundary>
{collection.isArchived && !collection.isDeleted && (
<Notice icon={<ArchiveIcon />}>
{t("Archived by {{userName}}", {
userName: collection.archivedBy?.name ?? t("Unknown"),
})}
&nbsp;
<Time dateTime={collection.archivedAt} addSuffix />
</Notice>
)}
</ErrorBoundary>
);
}
+46 -19
View File
@@ -13,11 +13,13 @@ import {
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Search from "~/scenes/Search";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import CollectionDescription from "~/components/CollectionDescription";
import Heading from "~/components/Heading";
import Icon, { IconTitleWrapper } from "~/components/Icon";
@@ -28,6 +30,7 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { editCollection } from "~/actions/definitions/collections";
@@ -41,6 +44,7 @@ import Actions from "./components/Actions";
import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import ShareButton from "./components/ShareButton";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -132,7 +136,9 @@ function CollectionScene() {
centered={false}
textTitle={collection.name}
left={
collection.isEmpty ? undefined : (
collection.isArchived ? (
<CollectionBreadcrumb collection={collection} />
) : collection.isEmpty ? undefined : (
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
@@ -163,6 +169,7 @@ function CollectionScene() {
collectionId={collection.id}
>
<CenteredContent withStickyHeader>
<Notices collection={collection} />
<CollectionHeading>
<IconTitleWrapper>
{can.update ? (
@@ -192,26 +199,28 @@ function CollectionScene() {
<CollectionDescription collection={collection} />
<Documents>
<Tabs>
<Tab to={collectionPath(collection.path)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionPath(collection.path, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionPath(collection.path, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
{!collection.isArchived && (
<Tabs>
<Tab to={collectionPath(collection.path)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionPath(collection.path, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionPath(collection.path, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
)}
{collection.isEmpty ? (
<Empty collection={collection} />
) : (
) : !collection.isArchived ? (
<Switch>
<Route path={collectionPath(collection.path, "alphabetical")}>
<PaginatedDocumentList
@@ -279,6 +288,24 @@ function CollectionScene() {
/>
</Route>
</Switch>
) : (
<Switch>
<Route path={collectionPath(collection.path)} exact>
<PaginatedDocumentList
documents={documents.archivedInCollection(collection.id)}
fetch={documents.fetchPage}
heading={<Subheading sticky>{t("Documents")}</Subheading>}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
statusFilter: [StatusFilter.Archived],
}}
showParentDocuments
/>
</Route>
</Switch>
)}
</Documents>
</CenteredContent>
+1 -1
View File
@@ -172,7 +172,7 @@ function SharedDocumentScene(props: Props) {
}
}
if (!response) {
if (!response?.sharedTree) {
return <Loading location={props.location} />;
}
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import { basicExtensions, withComments } from "@shared/editor/nodes";
import HardBreak from "@shared/editor/nodes/HardBreak";
@@ -36,4 +37,4 @@ const CommentEditor = (
);
};
export default React.forwardRef(CommentEditor);
export default observer(React.forwardRef(CommentEditor));
@@ -0,0 +1,86 @@
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import InputSelect from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import useQuery from "~/hooks/useQuery";
import { CommentSortType } from "~/types";
const CommentSortMenu = () => {
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const user = useCurrentUser();
const params = useQuery();
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved
? "resolved"
: user.getPreference(UserPreference.SortCommentsByOrderInDocument)
? CommentSortType.OrderInDocument
: CommentSortType.MostRecent;
const handleSortTypeChange = (type: CommentSortType) => {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
};
const showResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
});
};
const showUnresolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
});
};
return (
<Select
style={{ margin: 0 }}
ariaLabel={t("Sort comments")}
value={value}
onChange={(ev) => {
if (ev === "resolved") {
showResolved();
} else {
handleSortTypeChange(ev as CommentSortType);
showUnresolved();
}
}}
borderOnHover
options={[
{ value: CommentSortType.MostRecent, label: t("Most recent") },
{ value: CommentSortType.OrderInDocument, label: t("Order in doc") },
{
divider: true,
value: "resolved",
label: t("Resolved"),
},
]}
/>
);
};
const Select = styled(InputSelect)`
color: ${s("textSecondary")};
`;
export default CommentSortMenu;
@@ -80,6 +80,8 @@ function CommentThread({
});
const can = usePolicy(document);
const canReply = can.comment && !thread.isResolved;
const highlightedCommentMarks = editor
?.getComments()
.filter((comment) => comment.id === thread.id);
@@ -105,7 +107,7 @@ function CommentThread({
const handleClickThread = () => {
history.replace({
// Clear any commentId from the URL when explicitly focusing a thread
search: "",
search: thread.isResolved ? "resolved=" : "",
pathname: location.pathname.replace(/\/history$/, ""),
state: { commentId: thread.id },
});
@@ -190,8 +192,8 @@ function CommentThread({
<CommentThreadItem
highlightedText={index === 0 ? highlightedText : undefined}
comment={comment}
onDelete={() => editor?.removeComment(comment.id)}
onUpdate={(attrs) => editor?.updateComment(comment.id, attrs)}
onDelete={editor?.removeComment}
onUpdate={editor?.updateComment}
key={comment.id}
firstOfThread={index === 0}
lastOfThread={index === commentsInThread.length - 1 && !draft}
@@ -214,7 +216,7 @@ function CommentThread({
))}
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
{(focused || draft || commentsInThread.length === 0) && can.comment && (
{(focused || draft || commentsInThread.length === 0) && canReply && (
<Fade timing={100}>
<CommentForm
onSaveDraft={onSaveDraft}
@@ -232,7 +234,7 @@ function CommentThread({
</Fade>
)}
</ResizingHeightContainer>
{!focused && !recessed && !draft && can.comment && (
{!focused && !recessed && !draft && canReply && (
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}</Reply>
)}
</Thread>
@@ -1,5 +1,5 @@
import { differenceInMilliseconds } from "date-fns";
import { toJS } from "mobx";
import { action } from "mobx";
import { observer } from "mobx-react";
import { darken } from "polished";
import * as React from "react";
@@ -76,9 +76,9 @@ type Props = {
/** Whether the user can reply in the thread */
canReply: boolean;
/** Callback when the comment has been deleted */
onDelete: () => void;
onDelete?: (id: string) => void;
/** Callback when the comment has been updated */
onUpdate: (attrs: { resolved: boolean }) => void;
onUpdate?: (id: string, attrs: { resolved: boolean }) => void;
/** Text to highlight at the top of the comment */
highlightedText?: string;
};
@@ -96,8 +96,7 @@ function CommentThreadItem({
highlightedText,
}: Props) {
const { t } = useTranslation();
const [forceRender, setForceRender] = React.useState(0);
const [data, setData] = React.useState(toJS(comment.data));
const [data, setData] = React.useState(comment.data);
const showAuthor = firstOfAuthor;
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
const showEdited =
@@ -107,41 +106,48 @@ function CommentThreadItem({
const [isEditing, setEditing, setReadOnly] = useBoolean();
const formRef = React.useRef<HTMLFormElement>(null);
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
setData(value(false));
};
const handleUpdate = React.useCallback(
(attrs: { resolved: boolean }) => {
onUpdate?.(comment.id, attrs);
},
[comment.id, onUpdate]
);
const handleSave = () => {
const handleDelete = React.useCallback(() => {
onDelete?.(comment.id);
}, [comment.id, onDelete]);
const handleChange = React.useCallback(
(value: (asString: boolean) => ProsemirrorData) => {
setData(value(false));
},
[]
);
const handleSave = React.useCallback(() => {
formRef.current?.dispatchEvent(
new Event("submit", { cancelable: true, bubbles: true })
);
};
}, []);
const handleSubmit = async (event: React.FormEvent) => {
const handleSubmit = action(async (event: React.FormEvent) => {
event.preventDefault();
try {
setReadOnly();
await comment.save({
data,
});
comment.data = data;
await comment.save();
} catch (error) {
setEditing();
toast.error(t("Error updating comment"));
}
};
});
const handleCancel = () => {
setData(toJS(comment.data));
setData(comment.data);
setReadOnly();
setForceRender((i) => ++i);
};
React.useEffect(() => {
setData(toJS(comment.data));
setForceRender((i) => ++i);
}, [comment.data]);
return (
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
{firstOfAuthor && (
@@ -186,8 +192,9 @@ function CommentThreadItem({
)}
<Body ref={formRef} onSubmit={handleSubmit}>
<StyledCommentEditor
key={`${forceRender}`}
key={String(isEditing)}
readOnly={!isEditing}
value={comment.data}
defaultValue={data}
onChange={handleChange}
onSave={handleSave}
@@ -209,8 +216,8 @@ function CommentThreadItem({
<Menu
comment={comment}
onEdit={setEditing}
onDelete={onDelete}
onUpdate={onUpdate}
onDelete={handleDelete}
onUpdate={handleUpdate}
dir={dir}
/>
)}
+23 -69
View File
@@ -1,36 +1,34 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import styled, { css } from "styled-components";
import { ProsemirrorData } from "@shared/types";
import Button from "~/components/Button";
import { useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { ProsemirrorData, UserPreference } from "@shared/types";
import { useDocumentContext } from "~/components/DocumentContext";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { bigPulse } from "~/styles/animations";
import { CommentSortOption, CommentSortType } from "~/types";
import CommentForm from "./CommentForm";
import CommentSortMenu from "./CommentSortMenu";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";
function Comments() {
const { ui, comments, documents } = useStores();
const user = useCurrentUser();
const { editor, isEditorInitialized } = useDocumentContext();
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const match = useRouteMatch<{ documentSlug: string }>();
const params = useQuery();
const [pulse, setPulse] = React.useState(false);
const document = documents.getByUrl(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
@@ -42,71 +40,35 @@ function Comments() {
undefined
);
const sortOption: CommentSortOption = user.getPreference(
UserPreference.SortCommentsByOrderInDocument
)
? {
type: CommentSortType.OrderInDocument,
referencedCommentIds: editor?.getComments().map((c) => c.id) ?? [],
}
: { type: CommentSortType.MostRecent };
const viewingResolved = params.get("resolved") === "";
const resolvedThreads = document
? comments.resolvedThreadsInDocument(document.id)
? comments.resolvedThreadsInDocument(document.id, sortOption)
: [];
const resolvedThreadsCount = resolvedThreads.length;
React.useEffect(() => {
setPulse(true);
const timeout = setTimeout(() => setPulse(false), 250);
return () => {
clearTimeout(timeout);
setPulse(false);
};
}, [resolvedThreadsCount]);
if (!document) {
if (!document || !isEditorInitialized) {
return null;
}
const threads = viewingResolved
? resolvedThreads
: comments.unresolvedThreadsInDocument(document.id);
: comments.unresolvedThreadsInDocument(document.id, sortOption);
const hasComments = threads.length > 0;
const toggleViewingResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: viewingResolved ? undefined : "",
}),
pathname: location.pathname,
});
};
return (
<Sidebar
title={
<Flex align="center" justify="space-between" auto>
{viewingResolved ? (
<React.Fragment key="resolved">
<span>{t("Resolved comments")}</span>
<Tooltip delay={500} content={t("View comments")}>
<ResolvedButton
neutral
borderOnHover
icon={<DoneIcon />}
onClick={toggleViewingResolved}
/>
</Tooltip>
</React.Fragment>
) : (
<React.Fragment>
<span>{t("Comments")}</span>
<Tooltip delay={250} content={t("View resolved comments")}>
<ResolvedButton
neutral
borderOnHover
icon={<DoneIcon outline />}
onClick={toggleViewingResolved}
$pulse={pulse}
/>
</Tooltip>
</React.Fragment>
)}
<span>{t("Comments")}</span>
<CommentSortMenu />
</Flex>
}
onClose={() => ui.collapseComments(document?.id)}
@@ -158,14 +120,6 @@ function Comments() {
);
}
const ResolvedButton = styled(Button)<{ $pulse: boolean }>`
${(props) =>
props.$pulse &&
css`
animation: ${bigPulse} 250ms 1;
`}
`;
const PositionedEmpty = styled(Empty)`
position: absolute;
top: calc(50vh - 30px);
+17 -1
View File
@@ -175,7 +175,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[comments]
);
const { setEditor, updateState: updateDocState } = useDocumentContext();
const {
setEditor,
setEditorInitialized,
updateState: updateDocState,
} = useDocumentContext();
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
@@ -189,6 +193,16 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[childOffsetHeight]
);
const handleInit = React.useCallback(
() => setEditorInitialized(true),
[setEditorInitialized]
);
const handleDestroy = React.useCallback(
() => setEditorInitialized(false),
[setEditorInitialized]
);
return (
<Flex auto column>
<DocumentTitle
@@ -241,6 +255,8 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
? handleRemoveComment
: undefined
}
onInit={handleInit}
onDestroy={handleDestroy}
onChange={updateDocState}
extensions={extensions}
editorStyle={editorStyle}
+2 -2
View File
@@ -41,7 +41,7 @@ import DocumentMenu from "~/menus/DocumentMenu";
import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
import TemplatesMenu from "~/menus/TemplatesMenu";
import { metaDisplay } from "~/utils/keyboard";
import { altDisplay, metaDisplay } from "~/utils/keyboard";
import { documentEditPath } from "~/utils/routeHelpers";
import ObservingBanner from "./ObservingBanner";
import PublicBreadcrumb from "./PublicBreadcrumb";
@@ -124,7 +124,7 @@ function DocumentHeader({
? t("Show contents")
: `${t("Show contents")} (${t("available when headings are added")})`
}
shortcut="ctrl+alt+h"
shortcut={`ctrl+${altDisplay}+h`}
delay={250}
placement="bottom"
>
@@ -53,8 +53,6 @@ function ShareButton({ document }: Props) {
>
<SharePopover
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={popover.hide}
visible={popover.visible}
/>
+3 -2
View File
@@ -71,10 +71,11 @@ function Invite({ onSubmit }: Props) {
[onSubmit, invites, role, t, users]
);
const handleChange = React.useCallback((ev, index) => {
const handleChange = React.useCallback((ev, index: number) => {
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites[index][ev.target.name] = ev.target.value;
newInvites[index][ev.target.name as keyof InviteRequest] =
ev.target.value;
return newInvites;
});
}, []);
+70 -54
View File
@@ -57,7 +57,9 @@ function Search(props: Props) {
const recentSearchesRef = React.useRef<HTMLDivElement | null>(null);
// filters
const query = decodeURIComponentSafe(routeMatch.params.term ?? "");
const query = decodeURIComponentSafe(
routeMatch.params.term ?? params.get("query") ?? ""
);
const collectionId = params.get("collectionId") ?? undefined;
const userId = params.get("userId") ?? undefined;
const documentId = params.get("documentId") ?? undefined;
@@ -117,7 +119,12 @@ function Search(props: Props) {
const updateLocation = (query: string) => {
history.replace({
pathname: searchPath(query),
search: location.search,
search: queryString.stringify(
{ ...queryString.parse(location.search), query: undefined },
{
skipEmptyString: true,
}
),
});
};
@@ -134,7 +141,7 @@ function Search(props: Props) {
history.replace({
pathname: location.pathname,
search: queryString.stringify(
{ ...queryString.parse(location.search), ...search },
{ ...queryString.parse(location.search), query: undefined, ...search },
{
skipEmptyString: true,
}
@@ -201,59 +208,68 @@ function Search(props: Props) {
</div>
)}
<ResultsWrapper column auto>
<SearchInput
key={query ? "search" : "recent"}
ref={searchInputRef}
placeholder={`${
documentId
? t("Search in document")
: collectionId
? t("Search in collection")
: t("Search")
}`}
onKeyDown={handleKeyDown}
defaultValue={query}
/>
<form
method="GET"
action={searchPath()}
onSubmit={(ev) => ev.preventDefault()}
>
<SearchInput
name="query"
key={query ? "search" : "recent"}
ref={searchInputRef}
placeholder={`${
documentId
? t("Search in document")
: collectionId
? t("Search in collection")
: t("Search")
}`}
onKeyDown={handleKeyDown}
defaultValue={query}
/>
{(query || hasFilters) && (
<Filters>
{document && (
<DocumentFilter
document={document}
onClick={() => {
handleFilterChange({ documentId: undefined });
}}
{(query || hasFilters) && (
<Filters>
{document && (
<DocumentFilter
document={document}
onClick={() => {
handleFilterChange({ documentId: undefined });
}}
/>
)}
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/>
)}
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/>
<CollectionFilter
collectionId={collectionId}
onSelect={(collectionId) => handleFilterChange({ collectionId })}
/>
<UserFilter
userId={userId}
onSelect={(userId) => handleFilterChange({ userId })}
/>
<DateFilter
dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
<SearchTitlesFilter
width={26}
height={14}
label={t("Search titles only")}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
handleFilterChange({ titleFilter: ev.target.checked });
}}
checked={titleFilter}
/>
</Filters>
)}
<CollectionFilter
collectionId={collectionId}
onSelect={(collectionId) =>
handleFilterChange({ collectionId })
}
/>
<UserFilter
userId={userId}
onSelect={(userId) => handleFilterChange({ userId })}
/>
<DateFilter
dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
<SearchTitlesFilter
width={26}
height={14}
label={t("Search titles only")}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
handleFilterChange({ titleFilter: ev.target.checked });
}}
checked={titleFilter}
/>
</Filters>
)}
</form>
{query ? (
<>
{error ? (
+4 -4
View File
@@ -4,10 +4,10 @@ import styled, { useTheme } from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
type Props = React.HTMLAttributes<HTMLInputElement> & {
defaultValue?: string;
placeholder?: string;
};
interface Props extends React.HTMLAttributes<HTMLInputElement> {
name: string;
defaultValue: string;
}
function SearchInput(
{ defaultValue, ...rest }: Props,
+4 -1
View File
@@ -13,12 +13,14 @@ import Text from "~/components/Text";
import { createApiKey } from "~/actions/definitions/apiKeys";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import ApiKeyListItem from "./components/ApiKeyListItem";
function ApiKeys() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const { apiKeys } = useStores();
const can = usePolicy(team);
@@ -79,7 +81,8 @@ function ApiKeys() {
</Text>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem
+3 -1
View File
@@ -62,7 +62,9 @@ function Members() {
filter,
role,
});
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
if (response[PAGINATION_SYMBOL]) {
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
}
setUserIds(response.map((u: User) => u.id));
} finally {
setIsLoading(false);
+3 -1
View File
@@ -47,7 +47,9 @@ function Shares() {
sort,
direction,
});
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
if (response[PAGINATION_SYMBOL]) {
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
}
setShareIds(response.map((u: Share) => u.id));
} finally {
setIsLoading(false);
@@ -38,7 +38,7 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
[FileOperationState.Error]: t("Failed"),
};
const iconMapping = {
const iconMapping: Record<FileOperationState, React.JSX.Element> = {
[FileOperationState.Creating]: <Spinner />,
[FileOperationState.Uploading]: <Spinner />,
[FileOperationState.Expired]: <ArchiveIcon color={theme.textTertiary} />,
@@ -46,8 +46,9 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
[FileOperationState.Error]: <WarningIcon color={theme.danger} />,
};
const formatMapping = {
const formatMapping: Record<FileOperationFormat, string> = {
[FileOperationFormat.JSON]: "JSON",
[FileOperationFormat.Notion]: "Notion",
[FileOperationFormat.MarkdownZip]: "Markdown",
[FileOperationFormat.HTMLZip]: "HTML",
[FileOperationFormat.PDF]: "PDF",
+9
View File
@@ -1,3 +1,4 @@
import { computed } from "mobx";
import ApiKey from "~/models/ApiKey";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
@@ -8,4 +9,12 @@ export default class ApiKeysStore extends Store<ApiKey> {
constructor(rootStore: RootStore) {
super(rootStore, ApiKey);
}
@computed
get personalApiKeys() {
const userId = this.rootStore.auth.user?.id;
return userId
? this.orderedData.filter((key) => key.userId === userId)
: [];
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ import Team from "~/models/Team";
import User from "~/models/User";
import env from "~/env";
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
import { PartialWithId } from "~/types";
import { PartialExcept } from "~/types";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
import Logger from "~/utils/Logger";
@@ -20,8 +20,8 @@ import isCloudHosted from "~/utils/isCloudHosted";
import Store from "./base/Store";
type PersistedData = {
user?: PartialWithId<User>;
team?: PartialWithId<Team>;
user?: PartialExcept<User, "id">;
team?: PartialExcept<Team, "id">;
collaborationToken?: string;
availableTeams?: {
id: string;
+83 -3
View File
@@ -1,11 +1,16 @@
import invariant from "invariant";
import find from "lodash/find";
import isEmpty from "lodash/isEmpty";
import orderBy from "lodash/orderBy";
import sortBy from "lodash/sortBy";
import { computed, action } from "mobx";
import { CollectionPermission, FileOperationFormat } from "@shared/types";
import { computed, action, runInAction } from "mobx";
import {
CollectionPermission,
CollectionStatusFilter,
FileOperationFormat,
} from "@shared/types";
import Collection from "~/models/Collection";
import { Properties } from "~/types";
import { PaginationParams, Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store from "./base/Store";
@@ -27,6 +32,11 @@ export default class CollectionsStore extends Store<Collection> {
: undefined;
}
@computed
get allActive() {
return this.orderedData.filter((c) => c.isActive);
}
@computed
get orderedData(): Collection[] {
let collections = Array.from(this.data.values());
@@ -97,6 +107,30 @@ export default class CollectionsStore extends Store<Collection> {
}
};
@action
archive = async (collection: Collection) => {
const res = await client.post("/collections.archive", {
id: collection.id,
});
runInAction("Collection#archive", () => {
invariant(res?.data, "Data should be available");
this.add(res.data);
this.addPolicies(res.policies);
});
};
@action
restore = async (collection: Collection) => {
const res = await client.post("/collections.restore", {
id: collection.id,
});
runInAction("Collection#restore", () => {
invariant(res?.data, "Data should be available");
this.add(res.data);
this.addPolicies(res.policies);
});
};
async update(params: Properties<Collection>): Promise<Collection> {
const result = await super.update(params);
@@ -119,6 +153,52 @@ export default class CollectionsStore extends Store<Collection> {
return model;
}
@action
fetchNamedPage = async (
request = "list",
options:
| (PaginationParams & { statusFilter: CollectionStatusFilter[] })
| undefined
): Promise<Collection[]> => {
this.isFetching = true;
try {
const res = await client.post(`/collections.${request}`, options);
invariant(res?.data, "Collection list not available");
runInAction("CollectionsStore#fetchNamedPage", () => {
res.data.forEach(this.add);
this.addPolicies(res.policies);
this.isLoaded = true;
});
return res.data;
} finally {
this.isFetching = false;
}
};
@action
fetchArchived = async (options?: PaginationParams): Promise<Collection[]> =>
this.fetchNamedPage("list", {
...options,
statusFilter: [CollectionStatusFilter.Archived],
});
@computed
get archived(): Collection[] {
return orderBy(this.orderedData, "archivedAt", "desc").filter(
(c) => c.isArchived && !c.isDeleted
);
}
@computed
get publicCollections() {
return this.orderedData.filter(
(collection) =>
collection.permission &&
Object.values(CollectionPermission).includes(collection.permission)
);
}
star = async (collection: Collection, index?: string) => {
await this.rootStore.stars.create({
collectionId: collection.id,
+32 -6
View File
@@ -1,7 +1,12 @@
import invariant from "invariant";
import compact from "lodash/compact";
import differenceBy from "lodash/differenceBy";
import keyBy from "lodash/keyBy";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { action, computed } from "mobx";
import Comment from "~/models/Comment";
import { CommentSortOption, CommentSortType } from "~/types";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store from "./base/Store";
@@ -28,14 +33,29 @@ export default class CommentsStore extends Store<Comment> {
* @param documentId ID of the document to get comments for
* @returns Array of comments
*/
threadsInDocument(documentId: string): Comment[] {
return this.filter(
threadsInDocument(
documentId: string,
options: CommentSortOption = { type: CommentSortType.MostRecent }
) {
const comments = this.filter(
(comment: Comment) =>
comment.documentId === documentId &&
!comment.parentCommentId &&
(!comment.isNew ||
comment.createdById === this.rootStore.auth.currentUserId)
);
if (options.type === CommentSortType.MostRecent) {
return comments;
}
const commentsById = keyBy(comments, "id");
const referencedComments = compact(
uniq(options.referencedCommentIds.map((id) => commentsById[id]))
);
const directComments = differenceBy(comments, referencedComments, "id");
return [...referencedComments, ...directComments];
}
/**
@@ -45,8 +65,11 @@ export default class CommentsStore extends Store<Comment> {
* @param documentId ID of the document to get comments for
* @returns Array of comments
*/
resolvedThreadsInDocument(documentId: string): Comment[] {
return this.threadsInDocument(documentId).filter(
resolvedThreadsInDocument(
documentId: string,
options: CommentSortOption = { type: CommentSortType.MostRecent }
): Comment[] {
return this.threadsInDocument(documentId, options).filter(
(comment: Comment) => comment.isResolved === true
);
}
@@ -58,8 +81,11 @@ export default class CommentsStore extends Store<Comment> {
* @param documentId ID of the document to get comments for
* @returns Array of comments
*/
unresolvedThreadsInDocument(documentId: string): Comment[] {
return this.threadsInDocument(documentId).filter(
unresolvedThreadsInDocument(
documentId: string,
options: CommentSortOption = { type: CommentSortType.MostRecent }
): Comment[] {
return this.threadsInDocument(documentId, options).filter(
(comment: Comment) => comment.isResolved !== true
);
}
+41 -4
View File
@@ -21,7 +21,7 @@ import env from "~/env";
import type {
FetchOptions,
PaginationParams,
PartialWithId,
PartialExcept,
SearchResult,
} from "~/types";
import { client } from "~/utils/ApiClient";
@@ -121,6 +121,33 @@ export default class DocumentsStore extends Store<Document> {
);
}
archivedInCollection(
collectionId: string,
options?: { archivedAt: string }
): Document[] {
const filterCond = (document: Document) =>
options
? document.collectionId === collectionId &&
document.isArchived &&
document.archivedAt === options.archivedAt &&
!document.isDeleted
: document.collectionId === collectionId &&
document.isArchived &&
!document.isDeleted;
return filter(this.orderedData, filterCond);
}
unarchivedInCollection(collectionId: string): Document[] {
return filter(
this.orderedData,
(document) =>
document.collectionId === collectionId &&
!document.isArchived &&
!document.isDeleted
);
}
templatesInCollection(collectionId: string): Document[] {
return orderBy(
filter(
@@ -313,8 +340,18 @@ export default class DocumentsStore extends Store<Document> {
};
@action
fetchArchived = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("archived", options);
fetchArchived = async (options?: PaginationParams): Promise<Document[]> => {
const archivedInResponse = await this.fetchNamedPage("archived", options);
const archivedInMemory = this.archived;
archivedInMemory.forEach((docInMemory) => {
!archivedInResponse.find(
(docInResponse) => docInResponse.id === docInMemory.id
) && this.remove(docInMemory.id);
});
return archivedInResponse;
};
@action
fetchDeleted = async (options?: PaginationParams): Promise<Document[]> =>
@@ -489,7 +526,7 @@ export default class DocumentsStore extends Store<Document> {
super.fetch(
id,
options,
(res: { data: { document: PartialWithId<Document> } }) =>
(res: { data: { document: PartialExcept<Document, "id"> } }) =>
res.data.document
);
+7 -3
View File
@@ -5,7 +5,11 @@ import GroupMembership from "~/models/GroupMembership";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store, { PAGINATION_SYMBOL, RPCAction } from "./base/Store";
import Store, {
PAGINATION_SYMBOL,
PaginatedResponse,
RPCAction,
} from "./base/Store";
export default class GroupMembershipsStore extends Store<GroupMembership> {
actions = [RPCAction.Create, RPCAction.Delete];
@@ -24,7 +28,7 @@ export default class GroupMembershipsStore extends Store<GroupMembership> {
documentId?: string;
collectionId?: string;
groupId?: string;
}): Promise<GroupMembership[]> => {
}): Promise<PaginatedResponse<GroupMembership>> => {
this.isFetching = true;
try {
@@ -41,7 +45,7 @@ export default class GroupMembershipsStore extends Store<GroupMembership> {
: await client.post(`/groupMemberships.list`, params);
invariant(res?.data, "Data not available");
let response: GroupMembership[] = [];
let response: PaginatedResponse<GroupMembership> = [];
runInAction(`GroupMembershipsStore#fetchPage`, () => {
res.data.groups?.forEach(this.rootStore.groups.add);
res.data.documents?.forEach(this.rootStore.documents.add);
+7 -3
View File
@@ -5,7 +5,11 @@ import Membership from "~/models/Membership";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store, { PAGINATION_SYMBOL, RPCAction } from "./base/Store";
import Store, {
PAGINATION_SYMBOL,
PaginatedResponse,
RPCAction,
} from "./base/Store";
export default class MembershipsStore extends Store<Membership> {
actions = [RPCAction.Create, RPCAction.Delete];
@@ -17,14 +21,14 @@ export default class MembershipsStore extends Store<Membership> {
@action
fetchPage = async (
params: (PaginationParams & { id?: string }) | undefined
): Promise<Membership[]> => {
): Promise<PaginatedResponse<Membership>> => {
this.isFetching = true;
try {
const res = await client.post(`/collections.memberships`, params);
invariant(res?.data, "Data not available");
let response: Membership[] = [];
let response: PaginatedResponse<Membership> = [];
runInAction(`MembershipsStore#fetchPage`, () => {
res.data.users.forEach(this.rootStore.users.add);
response = res.data.memberships.map(this.add);
+10 -9
View File
@@ -102,14 +102,11 @@ export default class RootStore {
*
* @param modelName
*/
public getStoreForModelName<K extends keyof RootStore>(
modelName: string
): RootStore[K] {
public getStoreForModelName<K extends keyof RootStore>(modelName: string) {
const storeName = this.getStoreNameForModelName(modelName);
const store = this[storeName];
invariant(store, `No store found for model name "${modelName}"`);
return store;
return store as RootStore[K];
}
/**
@@ -118,8 +115,9 @@ export default class RootStore {
public clear() {
Object.getOwnPropertyNames(this)
.filter((key) => ["auth", "ui"].includes(key) === false)
.forEach((key) => {
this[key]?.clear?.();
.forEach((key: keyof RootStore) => {
// @ts-expect-error clear exists on all stores
"clear" in this[key] && this[key].clear();
});
}
@@ -128,7 +126,10 @@ export default class RootStore {
*
* @param StoreClass
*/
private registerStore<T = typeof Store>(StoreClass: T, name?: string) {
private registerStore<T = typeof Store>(
StoreClass: T,
name?: keyof RootStore
) {
// @ts-expect-error TS thinks we are instantiating an abstract class.
const store = new StoreClass(this);
const storeName = name ?? this.getStoreNameForModelName(store.modelName);
@@ -136,6 +137,6 @@ export default class RootStore {
}
private getStoreNameForModelName(modelName: string) {
return pluralize(lowerFirst(modelName));
return pluralize(lowerFirst(modelName)) as keyof RootStore;
}
}
+7 -1
View File
@@ -1,8 +1,10 @@
import { action, autorun, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
import Storage from "@shared/utils/Storage";
import Document from "~/models/Document";
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import { startViewTransition } from "~/utils/viewTransition";
import type RootStore from "./RootStore";
const UI_STORE = "UI_STORE";
@@ -140,7 +142,11 @@ class UiStore {
@action
setTheme = (theme: Theme) => {
this.theme = theme;
startViewTransition(() => {
flushSync(() => {
this.theme = theme;
});
});
Storage.set("theme", this.theme);
};
+64 -14
View File
@@ -11,12 +11,12 @@ import { Pagination } from "@shared/constants";
import { type JSONObject } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Policy from "~/models/Policy";
import ArchivableModel from "~/models/base/ArchivableModel";
import Model from "~/models/base/Model";
import { LifecycleManager } from "~/models/decorators/Lifecycle";
import { getInverseRelationsForModelClass } from "~/models/decorators/Relation";
import type { PaginationParams, PartialWithId, Properties } from "~/types";
import type { PaginationParams, PartialExcept, Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
export enum RPCAction {
@@ -28,10 +28,19 @@ export enum RPCAction {
Count = "count",
}
export type FetchPageParams = PaginationParams & Record<string, any>;
export const PAGINATION_SYMBOL = Symbol.for("pagination");
export type PaginatedResponse<T> = T[] & {
[PAGINATION_SYMBOL]?: {
total: number;
limit: number;
offset: number;
nextPath: string;
};
};
export type FetchPageParams = PaginationParams & Record<string, any>;
export default abstract class Store<T extends Model> {
@observable
data: Map<string, T> = new Map();
@@ -81,7 +90,7 @@ export default abstract class Store<T extends Model> {
};
@action
add = (item: PartialWithId<T> | T): T => {
add = (item: PartialExcept<T, "id"> | T): T => {
const ModelClass = this.model;
if (!(item instanceof ModelClass)) {
@@ -128,6 +137,7 @@ export default abstract class Store<T extends Model> {
if (deleteBehavior === "cascade") {
store.remove(item.id);
} else if (deleteBehavior === "null") {
// @ts-expect-error TODO
item[relation.idKey] = null;
}
});
@@ -144,6 +154,43 @@ export default abstract class Store<T extends Model> {
LifecycleManager.executeHooks(model.constructor, "afterRemove", model);
}
@action
addToArchive(item: ArchivableModel): void {
const inverseRelations = getInverseRelationsForModelClass(this.model);
inverseRelations.forEach((relation) => {
const store = this.rootStore.getStoreForModelName(relation.modelName);
if ("orderedData" in store) {
const items = (store.orderedData as ArchivableModel[]).filter(
(data) => data[relation.idKey] === item.id
);
items.forEach((item) => {
let archiveBehavior = relation.options.onArchive;
if (typeof relation.options.onArchive === "function") {
archiveBehavior = relation.options.onArchive(item);
}
if (archiveBehavior === "cascade") {
store.addToArchive(item);
} else if (archiveBehavior === "null") {
// @ts-expect-error TODO
item[relation.idKey] = null;
}
});
}
});
// Remove associated policies automatically, not defined through Relation decorator.
if (this.modelName !== "Policy") {
this.rootStore.policies.remove(item.id);
}
item.archivedAt = new Date().toISOString();
(this as unknown as Store<ArchivableModel>).add(item);
}
/**
* Remove all items in the store that match the predicate.
*
@@ -245,7 +292,7 @@ export default abstract class Store<T extends Model> {
async fetch(
id: string,
options: JSONObject = {},
accessor = (res: unknown) => (res as { data: PartialWithId<T> }).data
accessor = (res: unknown) => (res as { data: PartialExcept<T, "id"> }).data
): Promise<T> {
if (!this.actions.includes(RPCAction.Info)) {
throw new Error(`Cannot fetch ${this.modelName}`);
@@ -279,7 +326,9 @@ export default abstract class Store<T extends Model> {
}
@action
fetchPage = async (params?: FetchPageParams | undefined): Promise<T[]> => {
fetchPage = async (
params?: FetchPageParams | undefined
): Promise<PaginatedResponse<T>> => {
if (!this.actions.includes(RPCAction.List)) {
throw new Error(`Cannot list ${this.modelName}`);
}
@@ -290,7 +339,7 @@ export default abstract class Store<T extends Model> {
const res = await client.post(`/${this.apiEndpoint}.list`, params);
invariant(res?.data, "Data not available");
let response: T[] = [];
let response: PaginatedResponse<T> = [];
runInAction(`list#${this.modelName}`, () => {
this.addPolicies(res.policies);
@@ -306,15 +355,16 @@ export default abstract class Store<T extends Model> {
};
@action
fetchAll = async (params?: Record<string, any>): Promise<T[]> => {
fetchAll = async (
params?: Record<string, any>
): Promise<PaginatedResponse<T>> => {
const limit = params?.limit ?? Pagination.defaultLimit;
const response = await this.fetchPage({ ...params, limit });
if (!response[PAGINATION_SYMBOL]) {
Logger.warn("Pagination information not available in response", {
params,
});
}
invariant(
response[PAGINATION_SYMBOL],
"Pagination information not available in response"
);
const pages = Math.ceil(response[PAGINATION_SYMBOL].total / limit);
const fetchPages = [];
+15 -5
View File
@@ -14,7 +14,8 @@ import Pin from "./models/Pin";
import Star from "./models/Star";
import UserMembership from "./models/UserMembership";
export type PartialWithId<T> = Partial<T> & { id: string };
export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> &
Required<Pick<T, K>>;
export type MenuItemButton = {
type: "button";
@@ -188,10 +189,10 @@ export type WebsocketCollectionUpdateIndexEvent = {
};
export type WebsocketEvent =
| PartialWithId<Pin>
| PartialWithId<Star>
| PartialWithId<FileOperation>
| PartialWithId<UserMembership>
| PartialExcept<Pin, "id">
| PartialExcept<Star, "id">
| PartialExcept<FileOperation, "id">
| PartialExcept<UserMembership, "id">
| WebsocketCollectionUpdateIndexEvent
| WebsocketEntityDeletedEvent
| WebsocketEntitiesEvent;
@@ -214,3 +215,12 @@ export type Properties<C> = {
? Property
: never]?: C[Property];
};
export enum CommentSortType {
MostRecent = "mostRecent",
OrderInDocument = "orderInDocument",
}
export type CommentSortOption =
| { type: CommentSortType.MostRecent }
| { type: CommentSortType.OrderInDocument; referencedCommentIds: string[] };
+3 -2
View File
@@ -17,13 +17,14 @@ import {
getCurrentTimeAsString,
unicodeCLDRtoBCP47,
dateLocale,
locales,
} from "@shared/utils/date";
import User from "~/models/User";
export function dateToHeading(
dateTime: string,
t: TFunction,
userLocale: string | null | undefined
userLocale: keyof typeof locales | undefined
) {
const date = Date.parse(dateTime);
const now = new Date();
@@ -84,7 +85,7 @@ export function dateToHeading(
export function dateToExpiry(
dateTime: string,
t: TFunction,
userLocale: string | null | undefined
userLocale: keyof typeof locales | null | undefined
) {
const date = Date.parse(dateTime);
const now = new Date();
+2
View File
@@ -33,7 +33,9 @@ export default function download(
// reverse arguments, allowing download.bind(true, "text/xml", "export.xml") to act as a callback
// @ts-expect-error this is weird code
x = [x, m];
// @ts-expect-error this is weird code
m = x[0];
// @ts-expect-error this is weird code
x = x[1];
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { i18n } from "i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import { locales, unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Desktop from "./Desktop";
/**
@@ -25,7 +25,7 @@ export function formatNumber(number: number, locale: string) {
export function detectLanguage() {
const [ln, r] = navigator.language.split("-");
const region = (r || ln).toUpperCase();
return `${ln}_${region}`;
return `${ln}_${region}` as keyof typeof locales;
}
/**
+1 -1
View File
@@ -19,5 +19,5 @@ export function getVisibilityListener(): string {
}
export function getPageVisible(): boolean {
return !document[hidden];
return !document[hidden as keyof Document];
}
+3 -1
View File
@@ -25,7 +25,9 @@ export function settingsPath(section?: string): string {
}
export function commentPath(document: Document, comment: Comment): string {
return `${documentPath(document)}?commentId=${comment.id}`;
return `${documentPath(document)}?commentId=${comment.id}${
comment.isResolved ? "&resolved=" : ""
}`;
}
export function collectionPath(url: string, section?: string): string {
+13
View File
@@ -0,0 +1,13 @@
/**
* A simple wrapper around the startViewTransition API, if it exists. Otherwise
* it will just call the callback immediately.
*
* @param callback The callback to call inside the view transition.
*/
export const startViewTransition = (callback: UpdateCallback) => {
if (self.document.startViewTransition) {
self.document.startViewTransition(callback);
} else {
callback();
}
};
-1
View File
@@ -1,4 +1,3 @@
version: "3"
services:
redis:
image: redis
+18 -18
View File
@@ -58,7 +58,7 @@
"@babel/plugin-transform-class-properties": "^7.24.7",
"@babel/plugin-transform-destructuring": "^7.24.8",
"@babel/plugin-transform-regenerator": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"@babel/preset-env": "^7.25.8",
"@babel/preset-react": "^7.24.7",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^4.2.2",
@@ -73,7 +73,6 @@
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@getoutline/y-prosemirror": "^1.0.18",
"@hocuspocus/extension-throttle": "1.1.2",
"@hocuspocus/provider": "1.1.2",
"@hocuspocus/server": "1.1.2",
@@ -100,7 +99,7 @@
"class-validator": "^0.14.1",
"command-score": "^0.1.2",
"compressorjs": "^1.2.1",
"cookie": "^0.6.0",
"cookie": "^0.7.0",
"copy-to-clipboard": "^3.3.3",
"core-js": "^3.37.0",
"crypto-js": "^4.2.0",
@@ -111,7 +110,7 @@
"dotenv": "^16.4.5",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.3.0",
"emoji-regex": "^10.4.0",
"es6-error": "^4.1.1",
"fast-deep-equal": "^3.1.3",
"fetch-retry": "^5.0.6",
@@ -159,7 +158,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^6.9.14",
"octokit": "^3.2.1",
"outline-icons": "^3.8.0",
"outline-icons": "^3.10.0",
"oy-vey": "^0.12.1",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",
@@ -178,12 +177,12 @@
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.0",
"prosemirror-model": "^1.22.3",
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.4.0",
"prosemirror-transform": "^1.10.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.34.3",
"query-string": "^7.1.3",
"randomstring": "1.3.0",
@@ -239,11 +238,12 @@
"utility-types": "^3.10.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vite": "^5.3.1",
"vite": "^5.4.8",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.13.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
"y-prosemirror": "^1.2.12",
"y-protocols": "^1.0.6",
"yauzl": "^2.10.0",
"yjs": "^13.6.1",
@@ -253,7 +253,7 @@
"@babel/cli": "^7.23.4",
"@babel/preset-typescript": "^7.24.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.9",
"@relative-ci/agent": "^4.2.12",
"@testing-library/react": "^12.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
@@ -281,7 +281,7 @@
"@types/koa-send": "^4.1.6",
"@types/koa-sslify": "^4.0.6",
"@types/koa-useragent": "^2.1.2",
"@types/markdown-it": "^12.2.3",
"@types/markdown-it": "^14.1.2",
"@types/markdown-it-container": "^2.0.9",
"@types/markdown-it-emoji": "^2.0.4",
"@types/mermaid": "^9.2.0",
@@ -296,7 +296,7 @@
"@types/quoted-printable": "^1.0.2",
"@types/randomstring": "^1.3.0",
"@types/react": "^17.0.34",
"@types/react-avatar-editor": "^13.0.2",
"@types/react-avatar-editor": "^13.0.3",
"@types/react-color": "^3.0.12",
"@types/react-dom": "^17.0.11",
"@types/react-helmet": "^6.1.11",
@@ -307,7 +307,7 @@
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.15",
"@types/redis-info": "^3.0.3",
"@types/refractor": "^3.4.0",
"@types/refractor": "^3.4.1",
"@types/resolve-path": "^1.4.2",
"@types/semver": "^7.5.8",
"@types/sequelize": "^4.28.20",
@@ -316,7 +316,7 @@
"@types/styled-components": "^5.1.32",
"@types/throng": "^5.0.7",
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.4",
"@types/turndown": "^5.0.5",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.12.1",
"@types/yauzl": "^2.10.3",
@@ -329,7 +329,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.37.101",
"discord-api-types": "^0.37.102",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.6.3",
@@ -347,14 +347,14 @@
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^13.3.0",
"nodemon": "^3.1.4",
"nodemon": "^3.1.7",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"react-refresh": "^0.14.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^0.4.1",
"terser": "^5.32.0",
"typescript": "^5.4.5",
"typescript": "^5.6.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
@@ -362,7 +362,7 @@
"body-scroll-lock": "^4.0.0-beta.0",
"d3": "^7.0.0",
"debug": "4.3.4",
"node-fetch": "^2.6.12",
"node-fetch": "^2.7.0",
"js-yaml": "^3.14.1",
"qs": "6.9.7",
"rollup": "^4.5.1"
+2 -2
View File
@@ -69,9 +69,9 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
);
}
if (!organizationResponse) {
if (!organizationResponse?.value?.length) {
throw MicrosoftGraphError(
"Unable to load organization info from Microsoft Graph API"
`Unable to load organization info from Microsoft Graph API: ${organizationResponse.error?.message}`
);
}
+3 -4
View File
@@ -18,10 +18,9 @@ class AzurePluginEnvironment extends Environment {
);
@IsOptional()
@CannotUseWithout("AZURE_CLIENT_ID")
public AZURE_RESOURCE_APP_ID = this.toOptionalString(
environment.AZURE_RESOURCE_APP_ID
);
public AZURE_RESOURCE_APP_ID =
this.toOptionalString(environment.AZURE_RESOURCE_APP_ID) ??
"00000003-0000-0000-c000-000000000000";
@IsOptional()
@CannotUseWithout("AZURE_CLIENT_ID")
+6
View File
@@ -74,6 +74,12 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
if (!domain && !team) {
const userExists = await User.count({
where: { email: profile.email.toLowerCase() },
include: [
{
association: "team",
required: true,
},
],
});
// Users cannot create a team with personal gmail accounts

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