Compare commits

..

108 Commits

Author SHA1 Message Date
Tom Moor 7dd3c96b14 doc 2024-12-03 21:59:46 -05:00
Tom Moor f98d1203ea fix: Made the document header components more responsive to the available space 2024-12-03 21:59:05 -05:00
Tom Moor 5d09be4add More improvements to LaTeX fence detection 2024-12-02 22:28:59 -05:00
Tom Moor 48cae96a56 fix: Improve validation around emoji node serialization/deserialization 2024-12-02 21:58:22 -05:00
Tom Moor e8ab7a4885 chore: Add additional node validation 2024-12-02 21:22:58 -05:00
dependabot[bot] 183d02d5c6 chore(deps-dev): bump react-refresh from 0.14.0 to 0.14.2 (#8053)
Bumps [react-refresh](https://github.com/facebook/react/tree/HEAD/packages/react) from 0.14.0 to 0.14.2.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v0.14.2/packages/react)

---
updated-dependencies:
- dependency-name: react-refresh
  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-12-02 18:10:54 -08:00
Tom Moor 4b833b3e2e Add screen for API key management (#8049)
* API keys

* Add api key list for admins

* permissions
2024-12-02 18:10:36 -08:00
Translate-O-Tron d1b75d44f6 New Crowdin updates (#8040) 2024-12-02 14:36:10 -08:00
Tom Moor 8de59f0a2f fix: 'Resolve' button appearing on resolved threads, closes #8047 2024-12-01 09:53:38 -05:00
Tom Moor d8fbe35455 fix: Template variables are not applied on client (#8044)
* fix: Template variables are not applied on client

* test
2024-11-30 07:13:44 -08:00
Tom Moor 514a724d9d fix: Add default value for attachment preset for easier API use 2024-11-29 23:05:30 -05:00
Tom Moor d66f41c854 fix: Improve behavior of LaTeX at small screensizes, closes #8032 2024-11-29 11:20:01 -05:00
Tom Moor b2d6c40ea8 chore: Add warning for problematic selfhosted config, closes #8025 2024-11-29 11:07:23 -05:00
infinite-persistence c98d6aa33a Allow user to select doc-copy destination (#8030)
* DocumentExplorer: make style extensible

* Allow user to select doc-copy dest

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

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

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

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

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

* Rename: DuplicateDialog -> DocumentCopy

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

* Revert DocumentExplorer style override

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

* Switch to DocumentMove's style

* initialSelectionId -> defaultValue

---------

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

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

* Store in MB, rather than Bytes

* Add cron task to recalculate attachment sizes

* findAllInBatches

* Index createdAt

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

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

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

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

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

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

## Ticket
Closes 5079

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

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

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

* Handle cannot acquire lock

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

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

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

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

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

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

* wip

* wip
2024-11-16 10:45:14 -08:00
Tom Moor d73b4c55bf Mermaid v11 (#7964)
* mermaid-v11

* fix: Various rendering incompatibilities
2024-11-16 08:10:55 -08:00
Hemachandar 9843c4c995 Improvements around templates (#7952)
* hide new-doc-from-template menu item

* change trash path for deleted templates

* conditional show templates in command bar
2024-11-16 07:48:58 -08:00
dependabot[bot] 685397b057 chore(deps): bump cross-spawn from 7.0.3 to 7.0.5 (#7963)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.5.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.5)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-16 06:43:06 -08:00
Tom Moor 13d37d4207 perf: Fix increase in initial bundle size, prosemirror-transform must be fixed at 1.10.0 until paragraph join is fixed 2024-11-16 09:14:27 -05:00
Tom Moor 7bedfab301 fix: Mermaid diagrams in collapsed headings do not render, closes #7960 2024-11-15 23:43:05 -05:00
Translate-O-Tron db5850ac0d New Crowdin updates (#7898) 2024-11-15 18:15:32 -08:00
Hemachandar a4c40ce25e show resolved view when a resolved comment is opened from notif email (#7959)
* show resolved view when a resolved comment is opened from notif email

* check and replace state
2024-11-15 18:14:30 -08:00
Tom Moor f5457e79cd fix: Mermaid diagram moves up and down when focused in read-only editor 2024-11-14 20:00:55 -05:00
Hemachandar 73eeeefb25 fix: restore workspace templates (#7951) 2024-11-14 16:32:02 -08:00
Hemachandar 54f82cac96 fix: show all templates in the menu (#7950)
* fetch all templates

* websocket events for workspace templates
2024-11-14 16:31:48 -08:00
Tom Moor bb43c24efe fix: Improve handling of unknown errors – closes #7933 2024-11-13 21:22:06 -05:00
Tom Moor acf3d7cd08 fix: Add latex fences as markdown signal 2024-11-13 21:03:44 -05:00
github-actions[bot] 5245f93642 chore: Compressed inefficient images automatically (#7946)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2024-11-13 20:47:49 -05:00
Benjamin Kramser cfce55250e Add Pinterest embed (#7930)
* add pinterest embed

* improved profile detection
2024-11-13 17:46:07 -08:00
Tom Moor 6421995b29 fix: Do not override from address in self-hosted env, closes #7929 2024-11-13 20:19:17 -05:00
Tom Moor 8cfd8e25db fix: Event should not be written when API key is used 2024-11-13 09:10:30 -05:00
Tom Moor 1282e9653e fix: Excess padding on internal read-only docs, should only have applied to shares 2024-11-13 08:16:06 -05:00
Tom Moor f1edaecf49 perf: Fix observable changing on every keydown 2024-11-13 08:16:06 -05:00
Tom Moor f7d737ca45 fix: Missing 'Untitled' in reference list 2024-11-13 08:16:06 -05:00
Tom Moor 41c2c760d4 v0.81.0 2024-11-13 08:16:06 -05:00
dependabot[bot] f692d1bc3a chore(deps-dev): bump terser from 5.32.0 to 5.36.0 (#7928)
Bumps [terser](https://github.com/terser/terser) from 5.32.0 to 5.36.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.32.0...v5.36.0)

---
updated-dependencies:
- dependency-name: terser
  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-11-13 05:06:33 -08:00
dependabot[bot] 5197d6e18c chore(deps): bump prosemirror-view from 1.34.3 to 1.36.0 (#7925)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.34.3 to 1.36.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.34.3...1.36.0)

---
updated-dependencies:
- dependency-name: prosemirror-view
  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-11-13 05:06:16 -08:00
dependabot[bot] b901ea7b30 chore(deps-dev): bump eslint-plugin-react-hooks from 4.6.0 to 4.6.2 (#7924)
Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 4.6.0 to 4.6.2.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/eslint-plugin-react-hooks)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-hooks
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:05:21 -08:00
dependabot[bot] 3820499856 chore(deps-dev): bump @types/jest from 29.5.13 to 29.5.14 (#7927)
Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 29.5.13 to 29.5.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:05:11 -08:00
dependabot[bot] 0cffde63ab chore(deps): bump zod from 3.22.4 to 3.23.8 (#7926)
Bumps [zod](https://github.com/colinhacks/zod) from 3.22.4 to 3.23.8.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Changelog](https://github.com/colinhacks/zod/blob/main/CHANGELOG.md)
- [Commits](https://github.com/colinhacks/zod/compare/v3.22.4...v3.23.8)

---
updated-dependencies:
- dependency-name: zod
  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-11-13 05:05:01 -08:00
Hemachandar 449ba6488e hide share option for templates (#7937) 2024-11-13 05:03:36 -08:00
Tom Moor 62f3e6921f Add changes property to client event model 2024-11-10 22:58:51 -05:00
Tom Moor bc259316f7 Include changes in event presenter 2024-11-10 22:35:11 -05:00
Tom Moor 98e03cc227 Convert stars, towards #7920 (#7921) 2024-11-10 19:26:27 -08:00
Tom Moor 633e547d3e Refactor of event insertion (#5909) 2024-11-10 16:26:20 -08:00
Tom Moor d5de69fd4b fix: Exception for Notion import of a single document 2024-11-09 19:26:44 -05:00
Hemachandar feec01f160 fix: don't show comment marks for other users' drafts (#7838)
* fix: don't show comment marks for other users' drafts

* remove unnecessary draft check
2024-11-09 10:43:59 -08:00
Tom Moor aa5813032e fix: Click image to focus 2024-11-09 13:02:55 -05:00
Tom Moor a6ba189180 Add menu item to leave document that has been shared with current user (#7918)
* Add menu item to leave document that has been shared with current user

* Only redirect if viewing doc
2024-11-09 06:45:59 -08:00
Tom Moor 4c65bbc57c fix: Improved toolbar behavior with partial link selection, closes #7890 2024-11-08 22:41:58 -05:00
Tom Moor c76b4f46aa Tweak sharing UI 2024-11-08 21:35:55 -05:00
infinite-persistence ca17b41c53 share: add allowIndexing (#7896)
* share: add `allowIndexing`

## Ticket
Closes 7486

* i18n: follow existing no-punctuation style
2024-11-08 17:28:30 -08:00
Tom Moor 9747c6ba5d fix: Document mentions can be incorrectly attributed during collab session (#7913) 2024-11-08 05:35:49 -08:00
Tom Moor 55ffd6d098 feat: Adds support for importing CSV files (#7912)
* feat: Adds support for importing CSV files

* test

* tsc
2024-11-07 19:09:02 -08:00
Tom Moor 9b26ccda19 fix: Switching edit mode scrolls to page top, closes #7910 2024-11-07 22:04:15 -05:00
Hemachandar 56b38b9dbd fix: restore documents from a deleted collection (#7909) 2024-11-07 18:03:30 -08:00
Hemachandar 0a3a684493 fix: collection archival post-process parity with deletion (#7906) 2024-11-07 18:02:51 -08:00
226 changed files with 4847 additions and 3090 deletions
+8 -6
View File
@@ -4,12 +4,6 @@ defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
resource_class: large
environment:
NODE_ENV: test
@@ -78,6 +72,14 @@ jobs:
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.80.2
Licensed Work: Outline 0.81.0
The Licensed Work is (c) 2024 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2028-09-26
Change Date: 2028-11-11
Change License: Apache License, Version 2.0
+66 -8
View File
@@ -28,6 +28,7 @@ import {
EyeIcon,
PadlockIcon,
GlobeIcon,
LogoutIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -37,13 +38,14 @@ import {
NavigationNode,
} from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import UserMembership from "~/models/UserMembership";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DuplicateDialog from "~/components/DuplicateDialog";
import DocumentCopy from "~/components/DocumentCopy";
import Icon from "~/components/Icon";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
@@ -129,11 +131,30 @@ export const createDocumentFromTemplate = createAction({
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
!!stores.documents.get(activeDocumentId)?.template &&
stores.policies.abilities(currentTeamId).createDocument,
visible: ({
currentTeamId,
activeCollectionId,
activeDocumentId,
stores,
}) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
return false;
}
if (activeCollectionId) {
return stores.policies.abilities(activeCollectionId).createDocument;
}
return stores.policies.abilities(currentTeamId).createDocument;
},
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
history.push(
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
@@ -541,7 +562,7 @@ export const duplicateDocument = createAction({
stores.dialogs.openModal({
title: t("Copy document"),
content: (
<DuplicateDialog
<DocumentCopy
document={document}
onSubmit={(response) => {
stores.dialogs.closeAllModals();
@@ -1033,7 +1054,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.toggleComments(activeDocumentId);
stores.ui.toggleComments();
},
});
@@ -1119,6 +1140,42 @@ export const toggleViewerInsights = createAction({
},
});
export const leaveDocument = createAction({
name: ({ t }) => t("Leave document"),
analyticsName: "Leave document",
section: ActiveDocumentSection,
icon: <LogoutIcon />,
visible: ({ currentUserId, activeDocumentId, stores }) => {
const membership = stores.userMemberships.orderedData.find(
(m) => m.documentId === activeDocumentId && m.userId === currentUserId
);
return !!membership;
},
perform: async ({ t, location, currentUserId, activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
try {
if (document && location.pathname.startsWith(document.path)) {
history.push(homePath());
}
await stores.userMemberships.delete({
documentId: activeDocumentId,
userId: currentUserId,
} as UserMembership);
toast.success(t("You have left the shared document"));
} catch (err) {
toast.error(t("Could not leave document"));
}
},
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
@@ -1137,6 +1194,7 @@ export const rootDocumentActions = [
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
-1
View File
@@ -31,7 +31,6 @@ const Actions = styled(Flex)`
left: 0;
border-radius: 3px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
padding: 12px;
backdrop-filter: blur(20px);
+2 -1
View File
@@ -5,6 +5,7 @@ import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
export default observer(Authenticated);
+1 -1
View File
@@ -94,7 +94,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded.includes(ui.activeDocumentId) &&
ui.commentsExpanded &&
team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
+10 -11
View File
@@ -8,18 +8,16 @@ import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { MenuInternalLink } from "~/types";
type Props = {
type Props = React.PropsWithChildren<{
items: MenuInternalLink[];
max?: number;
highlightFirstItem?: boolean;
};
}>;
function Breadcrumb({
items,
highlightFirstItem,
children,
max = 2,
}: React.PropsWithChildren<Props>) {
function Breadcrumb(
{ items, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems;
@@ -37,7 +35,7 @@ function Breadcrumb({
}
return (
<Flex justify="flex-start" align="center">
<Flex justify="flex-start" align="center" ref={ref}>
{topLevelItems.map((item, index) => (
<React.Fragment key={String(item.to) || index}>
{item.icon}
@@ -67,6 +65,8 @@ const Slash = styled(GoToIcon)`
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
${ellipsis()}
${undraggableOnDesktop()}
display: flex;
flex-shrink: 1;
min-width: 0;
@@ -76,7 +76,6 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
height: 24px;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
${undraggableOnDesktop()}
svg {
flex-shrink: 0;
@@ -87,4 +86,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
}
`;
export default Breadcrumb;
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
+4 -2
View File
@@ -18,6 +18,8 @@ import useStores from "~/hooks/useStores";
type Props = {
/** The document to display live collaborators for */
document: Document;
/** The maximum number of collaborators to display, defaults to 6 */
limit?: number;
};
/**
@@ -25,6 +27,7 @@ type Props = {
* and presence status.
*/
function Collaborators(props: Props) {
const { limit = 6 } = props;
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
@@ -75,8 +78,6 @@ function Collaborators(props: Props) {
placement: "bottom-end",
});
const limit = 8;
return (
<>
<PopoverDisclosure {...popover}>
@@ -88,6 +89,7 @@ function Collaborators(props: Props) {
>
<Facepile
limit={limit}
overflow={collaborators.length - limit}
users={collaborators}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
-1
View File
@@ -201,7 +201,6 @@ const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
transition: ${s("backgroundTransition")};
&:after {
content: "";
@@ -2,7 +2,11 @@ import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import {
ActiveCollectionSection,
DocumentSection,
TeamSection,
} from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -11,26 +15,42 @@ const useTemplatesAction = () => {
const { documents } = useStores();
React.useEffect(() => {
void documents.fetchTemplates();
void documents.fetchAllTemplates();
}, [documents]);
const actions = React.useMemo(
() =>
documents.templatesAlphabetical.map((item) =>
documents.templatesAlphabetical.map((template) =>
createAction({
name: item.titleWithDefault,
name: template.titleWithDefault,
analyticsName: "New document",
section: DocumentSection,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
section: template.isWorkspaceTemplate
? TeamSection
: ActiveCollectionSection,
icon: template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<NewDocumentIcon />
),
keywords: "create",
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (activeCollectionId) {
return (
stores.policies.abilities(activeCollectionId).createDocument &&
(template.collectionId === activeCollectionId ||
template.isWorkspaceTemplate)
);
}
return (
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument &&
template.isWorkspaceTemplate
);
},
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(
newDocumentPath(item.collectionId ?? activeCollectionId, {
templateId: item.id,
newDocumentPath(template.collectionId ?? activeCollectionId, {
templateId: template.id,
}),
{
sidebarContext,
@@ -49,9 +69,15 @@ const useTemplatesAction = () => {
placeholder: ({ t }) => t("Choose a template"),
section: DocumentSection,
icon: <ShapesIcon />,
visible: ({ currentTeamId, stores }) =>
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (activeCollectionId) {
return stores.policies.abilities(activeCollectionId).createDocument;
}
return (
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
children: () => actions,
}),
[actions]
-1
View File
@@ -182,7 +182,6 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
const Content = styled.span`
background: ${s("background")};
transition: ${s("backgroundTransition")};
color: ${s("text")};
-webkit-text-fill-color: ${s("text")};
outline: none;
-16
View File
@@ -262,22 +262,6 @@ export const Position = styled.div`
transition-property: outline-width;
transition-duration: 0;
outline: none;
&:after {
content: "";
position: absolute;
top: 1px;
left: 1px;
right: 1px;
bottom: 1px;
pointer-events: none;
border-radius: 4px;
outline-color: ${s("accent")};
outline-width: initial;
outline-offset: -1px;
outline-style: solid;
}
}
/*
+7 -8
View File
@@ -57,11 +57,10 @@ function useCategory(document: Document): MenuInternalLink | null {
return null;
}
const DocumentBreadcrumb: React.FC<Props> = ({
document,
children,
onlyText,
}: Props) => {
function DocumentBreadcrumb(
{ document, children, onlyText }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
@@ -140,11 +139,11 @@ const DocumentBreadcrumb: React.FC<Props> = ({
}
return (
<Breadcrumb items={items} highlightFirstItem>
<Breadcrumb items={items} ref={ref} highlightFirstItem>
{children}
</Breadcrumb>
);
};
}
const StyledIcon = styled(Icon)`
margin-right: 2px;
@@ -160,4 +159,4 @@ const SmallSlash = styled(GoToIcon)`
opacity: 0.5;
`;
export default observer(DocumentBreadcrumb);
export default observer(React.forwardRef(DocumentBreadcrumb));
+3 -2
View File
@@ -39,6 +39,7 @@ function DocumentCard(props: Props) {
const { collections } = useStores();
const theme = useTheme();
const { document, pin, canUpdatePin, isDraggable } = props;
const pinnedToHome = React.useRef(!pin?.collectionId).current;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -122,13 +123,13 @@ function DocumentCard(props: Props) {
<Squircle
color={
collection?.color ??
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
(pinnedToHome ? theme.slateLight : theme.slateDark)
}
>
{collection?.icon &&
collection?.icon !== "letter" &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
pinnedToHome ? (
<CollectionIcon collection={collection} color="white" />
) : (
<DocumentIcon color="white" />
+149
View File
@@ -0,0 +1,149 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { flattenTree } from "~/utils/tree";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
document: Document;
onSubmit: (documents: Document[]) => void;
};
function DocumentCopy({ document, onSubmit }: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const collectionTrees = useCollectionTrees();
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
null
);
const items = React.useMemo(() => {
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const copy = async () => {
if (!selectedPath) {
toast.message(t("Select a location to copy"));
return;
}
try {
const result = await document.duplicate({
publish,
recursive,
title: document.title,
collectionId: selectedPath.collectionId,
...(selectedPath.type === "document"
? { parentDocumentId: selectedPath.id }
: {}),
});
toast.success(t("Document copied"));
onSubmit(result);
} catch (err) {
toast.error(t("Couldnt copy the document, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={copy}
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
<OptionsContainer>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
values={{ location: selectedPath.title }}
components={{ em: <strong /> }}
/>
) : (
t("Select a location to copy")
)}
</StyledText>
<Button disabled={!selectedPath} onClick={copy}>
{t("Copy")}
</Button>
</Footer>
</FlexContainer>
);
}
const OptionsContainer = styled.div`
margin: 16px 0 8px 0;
padding-left: 24px;
padding-right: 24px;
`;
export default observer(DocumentCopy);
+27 -5
View File
@@ -31,15 +31,15 @@ import { ancestors, descendants } from "~/utils/tree";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
onSubmit: () => void;
/** A side-effect of item selection */
onSelect: (item: NavigationNode | null) => void;
/** Items to be shown in explorer */
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
};
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -47,12 +47,25 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
null
() => {
const node =
defaultValue && items.find((item) => item.id === defaultValue);
return node || null;
}
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
const node = items.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((node) => node.id);
}
}
return [];
});
const [itemRefs, setItemRefs] = React.useState<
React.RefObject<HTMLSpanElement>[]
>([]);
@@ -94,6 +107,15 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, []);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
-97
View File
@@ -1,97 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { DocumentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input from "./Input";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
document: Document;
onSubmit: (documents: Document[]) => void;
};
function DuplicateDialog({ document, onSubmit }: Props) {
const { t } = useTranslation();
const defaultTitle = t(`Copy of {{ documentName }}`, {
documentName: document.title,
});
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [title, setTitle] = React.useState<string>(defaultTitle);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const handleTitleChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setTitle(ev.target.value);
},
[]
);
const handleSubmit = async () => {
const result = await document.duplicate({
publish,
recursive,
title,
});
onSubmit(result);
};
return (
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Duplicate")}>
<Input
autoFocus
autoSelect
name="title"
label={t("Title")}
onChange={handleTitleChange}
maxLength={DocumentValidation.maxTitleLength}
defaultValue={defaultTitle}
/>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</ConfirmationDialog>
);
}
export default observer(DuplicateDialog);
+9 -8
View File
@@ -268,14 +268,15 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
{props.editorStyle?.paddingBottom &&
(!props.readOnly || props.shareId) && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
</>
</ErrorBoundary>
);
+1 -1
View File
@@ -27,7 +27,7 @@ import { documentHistoryPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
event: Event;
event: Event<Document>;
latest?: boolean;
};
+1 -1
View File
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
${Outline} {
border: none;
border-radius: 0;
border-bottom: 1px solid ${s("inputBorder")};
border-bottom: 1px solid rgb(34 40 52);
background: ${s("menuBackground")};
}
-1
View File
@@ -94,7 +94,6 @@ const Scene = styled.div`
align-items: flex-start;
width: 350px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
border-radius: 8px;
outline: none;
opacity: 0;
+25 -9
View File
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
import { MenuIcon } from "outline-icons";
import { transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
@@ -10,6 +11,7 @@ import { supportsPassiveListener } from "@shared/utils/browser";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
@@ -21,16 +23,22 @@ export const HEADER_HEIGHT = 64;
type Props = {
left?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
actions?:
| ((props: { isCompact: boolean }) => React.ReactNode)
| React.ReactNode;
hasSidebar?: boolean;
className?: string;
};
function Header({ left, title, actions, hasSidebar, className }: Props) {
function Header(
{ left, title, actions, hasSidebar, className }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const internalRef = React.useRef<HTMLDivElement | null>(null);
const breadcrumbsRef = React.useRef<HTMLDivElement | null>(null);
const passThrough = !actions && !left && !title;
const [isScrolled, setScrolled] = React.useState(false);
@@ -53,8 +61,18 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
});
}, []);
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement) => {
breadcrumbsRef.current = node.firstElementChild as HTMLDivElement;
}, []);
const size = useComponentSize(internalRef);
const breadcrumbsSize = useComponentSize(breadcrumbsRef);
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
return (
<Wrapper
ref={mergeRefs([ref, internalRef])}
align="center"
shrink={false}
className={className}
@@ -62,7 +80,7 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs>
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
@@ -74,7 +92,7 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
</Breadcrumbs>
) : null}
{isScrolled ? (
{isScrolled && !isCompact ? (
<Title onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
@@ -82,7 +100,7 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
<div />
)}
<Actions align="center" justify="flex-end">
{actions}
{typeof actions === "function" ? actions({ isCompact }) : actions}
</Actions>
</Wrapper>
);
@@ -130,7 +148,6 @@ const Wrapper = styled(Flex)<WrapperProps>`
`};
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
min-height: ${HEADER_HEIGHT}px;
justify-content: flex-start;
@@ -152,7 +169,6 @@ const Wrapper = styled(Flex)<WrapperProps>`
${breakpoint("tablet")`
padding: 16px;
justify-content: center;
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
`};
`;
@@ -191,4 +207,4 @@ const MobileMenuButton = styled(Button)`
}
`;
export default observer(Header);
export default observer(React.forwardRef(Header));
+4 -2
View File
@@ -47,14 +47,16 @@ export default function LanguagePrompt() {
<br />
<Link
onClick={async () => {
ui.setLanguagePromptDismissed();
ui.set({ languagePromptDismissed: true });
await user.save({ language });
}}
>
{t("Change Language")}
</Link>{" "}
&middot;{" "}
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
<Link onClick={() => ui.set({ languagePromptDismissed: true })}>
{t("Dismiss")}
</Link>
</span>
</Flex>
</Wrapper>
-1
View File
@@ -76,7 +76,6 @@ const Layout = React.forwardRef(function Layout_(
const Container = styled(Flex)`
background: ${s("background")};
transition: ${s("backgroundTransition")};
position: relative;
width: 100%;
min-height: 100%;
-2
View File
@@ -174,7 +174,6 @@ const Fullscreen = styled.div<FullscreenProps>`
justify-content: center;
align-items: flex-start;
background: ${s("background")};
transition: ${s("backgroundTransition")};
outline: none;
${breakpoint("tablet")`
@@ -265,7 +264,6 @@ const Small = styled.div`
justify-content: center;
align-items: flex-start;
background: ${s("modalBackground")};
transition: ${s("backgroundTransition")};
box-shadow: ${s("modalShadow")};
border-radius: 8px;
outline: none;
+5 -3
View File
@@ -6,9 +6,11 @@ import PaginatedList from "~/components/PaginatedList";
import EventListItem from "./EventListItem";
type Props = {
events: Event[];
events: Event<Document>[];
document: Document;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
fetch: (
options: Record<string, any> | undefined
) => Promise<Event<Document>[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -30,7 +32,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event, index) => (
renderItem={(item: Event<Document>, index) => (
<EventListItem
key={item.id}
event={item}
-1
View File
@@ -144,7 +144,6 @@ const EmojiButton = styled(NudeButton)<{
height: 28px;
padding: 6px;
border-radius: 12px;
transition: ${s("backgroundTransition")};
background: ${s("backgroundTertiary")};
pointer-events: ${({ disabled }) => disabled && "none"};
+16 -13
View File
@@ -2,7 +2,6 @@ import { ReactionIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import styled from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
@@ -11,6 +10,7 @@ import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
const EmojiPanel = React.lazy(
() => import("~/components/IconPicker/components/EmojiPanel")
@@ -98,15 +98,22 @@ const ReactionPicker: React.FC<Props> = ({
<>
<PopoverDisclosure {...popover}>
{(props) => (
<PopoverButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
size={size}
<Tooltip
content={t("Add reaction")}
placement="top"
delay={500}
hideOnClick
>
<ReactionIcon size={22} />
</PopoverButton>
<NudeButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
size={size}
>
<ReactionIcon size={22} />
</NudeButton>
</Tooltip>
)}
</PopoverDisclosure>
<Popover
@@ -151,8 +158,4 @@ const Placeholder = React.memo(
);
Placeholder.displayName = "ReactionPickerPlaceholder";
const PopoverButton = styled(NudeButton)`
border-radius: 50%;
`;
export default ReactionPicker;
@@ -1,12 +1,13 @@
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon, InfoIcon } from "outline-icons";
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import Flex from "@shared/components/Flex";
import Squircle from "@shared/components/Squircle";
import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -50,6 +51,19 @@ function PublicAccess({ document, share, sharedParent }: Props) {
setUrlId(share?.urlId);
}, [share?.urlId]);
const handleIndexingChanged = React.useCallback(
async (event) => {
try {
await share?.save({
allowIndexing: event.currentTarget.checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (event) => {
try {
@@ -153,6 +167,32 @@ function PublicAccess({ document, share, sharedParent }: Props) {
/>
<ResizingHeightContainer>
{share?.published && (
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Search engine indexing")}&nbsp;
<Tooltip
content={t(
"Disable this setting to discourage search engines from indexing the page"
)}
>
<QuestionMarkIcon size={18} />
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Search engine indexing")}
checked={share?.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
)}
{sharedParent?.published ? (
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
{copyButton}
+3 -3
View File
@@ -32,13 +32,13 @@ function Right({ children, border, className }: Props) {
Math.min(window.innerWidth - event.pageX, maxWidth),
minWidth
);
ui.setRightSidebarWidth(width);
ui.set({ sidebarRightWidth: width });
},
[minWidth, maxWidth, ui]
);
const handleReset = React.useCallback(() => {
ui.setRightSidebarWidth(theme.sidebarRightWidth);
ui.set({ sidebarRightWidth: theme.sidebarRightWidth });
}, [ui, theme.sidebarRightWidth]);
const handleStopDrag = React.useCallback(() => {
@@ -128,7 +128,7 @@ const Sidebar = styled(m.div)<{
max-width: 80%;
border-left: 1px solid ${s("divider")};
transition: border-left 100ms ease-in-out;
z-index: 1;
z-index: ${depths.sidebar};
${breakpoint("mobile", "tablet")`
display: flex;
+14 -16
View File
@@ -46,7 +46,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const setWidth = ui.setSidebarWidth;
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
const [isAnimating, setAnimating] = React.useState(false);
@@ -62,13 +61,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const width = Math.min(event.pageX - offset, maxWidth);
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setWidth(theme.sidebarCollapsedWidth);
} else {
setWidth(width);
}
ui.set({
sidebarWidth: isSmallerThanCollapsePoint
? theme.sidebarCollapsedWidth
: width,
});
},
[theme, offset, minWidth, maxWidth, setWidth]
[ui, theme, offset, minWidth, maxWidth]
);
const handleStopDrag = React.useCallback(() => {
@@ -86,13 +85,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
setCollapsing(true);
ui.collapseSidebar();
} else {
setWidth(minWidth);
ui.set({ sidebarWidth: minWidth });
setAnimating(true);
}
} else {
setWidth(width);
ui.set({ sidebarWidth: width });
}
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
}, [ui, isSmallerThanMinimum, minWidth, width]);
const handleBlur = React.useCallback(() => {
setHovering(false);
@@ -149,11 +148,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
React.useEffect(() => {
if (isCollapsing) {
setTimeout(() => {
setWidth(minWidth);
ui.set({ sidebarWidth: minWidth });
setCollapsing(false);
}, ANIMATION_MS);
}
}, [setWidth, minWidth, isCollapsing]);
}, [ui, minWidth, isCollapsing]);
React.useEffect(() => {
if (isResizing) {
@@ -174,7 +173,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
}, [isResizing, handleDrag, handleBlur, handleStopDrag]);
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
ui.set({ sidebarWidth: theme.sidebarWidth });
}, [ui, theme.sidebarWidth]);
React.useEffect(() => {
@@ -299,9 +298,8 @@ const Container = styled(Flex)<ContainerProps>`
width: 100%;
background: ${s("sidebarBackground")};
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
${s("backgroundTransition")}
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
@@ -112,8 +112,9 @@ const NavLink = ({
!rest.target &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey,
[rest.target]
!event.ctrlKey &&
!isActive,
[rest.target, isActive]
);
const navigateTo = React.useCallback(() => {
@@ -153,14 +154,13 @@ const NavLink = ({
<Link
key={isActive ? "active" : "inactive"}
ref={linkRef}
// onMouseDown={handleClick}
onClick={handleClick}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
}}
onClick={handleClick}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -105,7 +105,6 @@ const Button = styled(Flex)<{
&:hover,
&[aria-expanded="true"] {
color: ${s("sidebarText")};
transition: background 100ms ease-in-out;
background: ${s("sidebarActiveBackground")};
}
@@ -78,7 +78,6 @@ function SidebarLink(
const activeStyle = React.useMemo(
() => ({
fontWeight: 600,
color: theme.text,
background: theme.sidebarActiveBackground,
...style,
@@ -202,10 +201,10 @@ const Link = styled(NavLink)<{
display: flex;
position: relative;
text-overflow: ellipsis;
font-weight: 475;
padding: 6px 16px;
border-radius: 4px;
min-height: 32px;
transition: background 50ms, color 50ms;
user-select: none;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
@@ -48,13 +48,20 @@ function StarredLink({ star }: Props) {
React.useEffect(() => {
if (
star.documentId === ui.activeDocumentId &&
sidebarContext === locationSidebarContext
) {
setExpanded(true);
} else if (
star.collectionId === ui.activeCollectionId &&
sidebarContext === locationSidebarContext
) {
setExpanded(true);
}
}, [
star.documentId,
star.collectionId,
ui.activeDocumentId,
ui.activeCollectionId,
sidebarContext,
locationSidebarContext,
-1
View File
@@ -31,7 +31,6 @@ const Background = styled.div<{ sticky?: boolean }>`
margin: 0 -8px;
padding: 0 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
z-index: 1;
`;
+2 -3
View File
@@ -253,6 +253,7 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
white-space: nowrap;
margin: 0 -4px;
padding: 0 4px;
cursor: ${(props) => (props.$sortable ? `var(--pointer)` : "")};
&:hover {
background: ${(props) =>
@@ -309,15 +310,13 @@ const Row = styled.tr`
const Head = styled.th`
text-align: left;
padding: 6px 6px 0;
padding: 6px 6px 2px;
border-bottom: 1px solid ${s("divider")};
background: ${s("background")};
transition: ${s("backgroundTransition")};
font-size: 14px;
color: ${s("textSecondary")};
font-weight: 500;
z-index: 1;
cursor: var(--pointer) !important;
:first-child {
padding-left: 0;
-1
View File
@@ -45,7 +45,6 @@ const Sticky = styled.div`
margin: 0 -8px;
padding: 0 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
z-index: 1;
`;
-42
View File
@@ -1,42 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
/** The size to render the indicator, defaults to 24px */
size?: number;
};
/**
* A component to show an animated typing indicator.
*/
export default function Typing({ size = 24 }: Props) {
return (
<Wrapper height={size} width={size}>
<Circle cx={size / 4} cy={size / 2} r="2" />
<Circle cx={size / 2} cy={size / 2} r="2" />
<Circle cx={size / 1.33333} cy={size / 2} r="2" />
</Wrapper>
);
}
const Wrapper = styled.svg`
fill: ${s("textTertiary")};
@keyframes blink {
50% {
fill: transparent;
}
}
`;
const Circle = styled.circle`
animation: 1s blink infinite;
&:nth-child(2) {
animation-delay: 250ms;
}
&:nth-child(3) {
animation-delay: 500ms;
}
`;
-7
View File
@@ -529,13 +529,6 @@ class WebsocketProvider extends React.Component<Props> {
stars.remove(event.modelId);
});
this.socket.on(
"user.typing",
(event: { userId: string; documentId: string; commentId: string }) => {
comments.setTyping(event);
}
);
this.socket.on("collections.add_user", async (event: Membership) => {
memberships.add(event);
await collections.fetch(event.collectionId, {
+6 -4
View File
@@ -131,13 +131,15 @@ function usePosition({
// Images need their own positioning to get the toolbar in the center
if (isImageSelection) {
const element = view.nodeDOM(selection.from) as HTMLElement;
const element = view.nodeDOM(selection.from);
// Images are wrapped which impacts positioning - need to get the element
// specifically tagged as the handle
const imageElement = element.getElementsByClassName(
EditorStyleHelper.imageHandle
)[0];
const imageElement = element
? (element as HTMLElement).getElementsByClassName(
EditorStyleHelper.imageHandle
)[0]
: undefined;
if (imageElement) {
const { left, top, width } = imageElement.getBoundingClientRect();
+7 -7
View File
@@ -216,8 +216,7 @@ export default function SelectionToolbar(props: Props) {
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
const link = isMarkActive(state.schema.marks.link)(state);
const range = getMarkRange(selection.$from, state.schema.marks.link);
const link = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
const isAttachmentSelection =
@@ -266,7 +265,8 @@ export default function SelectionToolbar(props: Props) {
return null;
}
const showLinkToolbar = link && range;
const showLinkToolbar =
link && link.from === selection.from && link.to === selection.to;
return (
<FloatingToolbar
@@ -276,12 +276,12 @@ export default function SelectionToolbar(props: Props) {
>
{showLinkToolbar ? (
<LinkEditor
key={`${range.from}-${range.to}`}
key={`${link.from}-${link.to}`}
dictionary={dictionary}
view={view}
mark={range.mark}
from={range.from}
to={range.to}
mark={link.mark}
from={link.from}
to={link.to}
onClickLink={props.onClickLink}
onSearchLink={props.onSearchLink}
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
+25 -6
View File
@@ -92,6 +92,10 @@ export default class FindAndReplaceExtension extends Extension {
public replace(replace: string): Command {
return (state, dispatch) => {
// Redo the search to ensure we have the latest results, the document may
// have changed underneath us since the last search.
this.search(state.doc);
const result = this.results[this.currentResultIndex];
if (!result) {
@@ -106,7 +110,12 @@ export default class FindAndReplaceExtension extends Extension {
}
public replaceAll(replace: string): Command {
return ({ tr }, dispatch) => {
return (state, dispatch) => {
// Redo the search to ensure we have the latest results, the document may
// have changed underneath us since the last search.
this.search(state.doc);
const tr = state.tr;
let offset: number | undefined;
if (!this.results.length) {
@@ -248,15 +257,25 @@ export default class FindAndReplaceExtension extends Extension {
let m;
const search = this.findRegExp;
while ((m = search.exec(deburr(text)))) {
// We construct a string with the text stripped of diacritics plus the original text for
// search allowing to search for diacritics-insensitive matches easily.
while ((m = search.exec(deburr(text) + text))) {
if (m[0] === "") {
break;
}
this.results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
// Reconstruct the correct match position
const i = m.index >= text.length ? m.index - text.length : m.index;
const from = pos + i;
const to = from + m[0].length;
// Check if already exists in results, possible due to duplicated
// search string on L257
if (this.results.some((r) => r.from === from && r.to === to)) {
continue;
}
this.results.push({ from, to });
}
} catch (e) {
// Invalid RegExp
+7 -6
View File
@@ -1,5 +1,4 @@
import isEqual from "lodash/isEqual";
import { keymap } from "prosemirror-keymap";
import {
ySyncPlugin,
yCursorPlugin,
@@ -104,11 +103,13 @@ export default class Multiplayer extends Extension {
selectionBuilder,
}),
yUndoPlugin(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
];
}
commands() {
return {
undo: () => undo,
redo: () => redo,
};
}
}
+4 -16
View File
@@ -4,7 +4,6 @@ import { darken, transparentize } from "polished";
import { baseKeymap } from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import { redo, undo } from "prosemirror-history";
import { inputRules, InputRule } from "prosemirror-inputrules";
import { keymap } from "prosemirror-keymap";
import { MarkdownParser } from "prosemirror-markdown";
@@ -608,20 +607,6 @@ export class Editor extends React.PureComponent<
this.props
);
/**
* Undo the last change in the editor.
*
* @returns True if the undo was successful
*/
public undo = () => undo(this.view.state, this.view.dispatch, this.view);
/**
* Redo the last change in the editor.
*
* @returns True if the change was successful
*/
public redo = () => redo(this.view.state, this.view.dispatch, this.view);
/**
* Returns true if the trimmed content of the editor is an empty string.
*
@@ -690,7 +675,10 @@ export class Editor extends React.PureComponent<
* @param commentId The id of the comment to remove
* @param attrs The attributes to update
*/
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
public updateComment = (
commentId: string,
attrs: { resolved?: boolean; draft?: boolean }
) => {
const { state, dispatch } = this.view;
const tr = state.tr;
-1
View File
@@ -214,7 +214,6 @@ export default function formattingMenuItems(
name: "link",
tooltip: dictionary.createLink,
icon: <LinkIcon />,
active: isMarkActive(schema.marks.link),
attrs: { href: "" },
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
+1 -1
View File
@@ -31,7 +31,7 @@ export default function useAutoRefresh() {
return;
}
if (isReloaded) {
Logger.error("lifecycle", new Error("Attempted to reload twice"));
Logger.warn("Attempted to reload twice");
return;
}
+5 -4
View File
@@ -6,10 +6,7 @@ export default function useComponentSize(
width: number;
height: number;
} {
const [size, setSize] = useState({
width: ref.current?.clientWidth || 0,
height: ref.current?.clientHeight || 0,
});
const [size, setSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver((entries) => {
@@ -24,6 +21,10 @@ export default function useComponentSize(
});
if (ref.current) {
setSize({
width: ref.current?.clientWidth,
height: ref.current?.clientHeight,
});
sizeObserver.observe(ref.current);
}
+13 -4
View File
@@ -29,6 +29,7 @@ import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
@@ -87,10 +88,10 @@ const useSettingsConfig = () => {
icon: EmailIcon,
},
{
name: t("API"),
path: settingsPath("tokens"),
component: ApiKeys,
enabled: can.createApiKey,
name: t("API Keys"),
path: settingsPath("personal-api-keys"),
component: PersonalApiKeys,
enabled: can.createApiKey && !can.listApiKeys,
group: t("Account"),
icon: CodeIcon,
},
@@ -143,6 +144,14 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: ShapesIcon,
},
{
name: t("API Keys"),
path: settingsPath("api-keys"),
component: ApiKeys,
enabled: can.listApiKeys,
group: t("Workspace"),
icon: CodeIcon,
},
{
name: t("Shared Links"),
path: settingsPath("shares"),
+2
View File
@@ -47,6 +47,7 @@ import {
shareDocument,
copyDocument,
searchInDocument,
leaveDocument,
moveTemplate,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
@@ -298,6 +299,7 @@ const MenuContent: React.FC<MenuContentProps> = ({
},
actionToMenuItem(deleteDocument, context),
actionToMenuItem(permanentlyDeleteDocument, context),
actionToMenuItem(leaveDocument, context),
]}
/>
{(showDisplayOptions || showToggleEmbeds) && can.update && (
+15 -6
View File
@@ -1,8 +1,9 @@
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import { DocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
@@ -11,14 +12,17 @@ import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { replaceTitleVariables } from "~/utils/date";
type Props = {
/** The document to which the templates will be applied */
document: Document;
/** Whether to render the button as a compact icon */
isCompact?: boolean;
/** Callback to handle when a template is selected */
onSelectTemplate: (template: Document) => void;
};
function TemplatesMenu({ onSelectTemplate, document }: Props) {
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
const menu = useMenuState({
modal: true,
});
@@ -29,7 +33,7 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
const templateToMenuItem = React.useCallback(
(tmpl: Document): MenuItem => ({
type: "button",
title: replaceTitleVariables(tmpl.titleWithDefault, user),
title: TextHelper.replaceTemplateVariables(tmpl.titleWithDefault, user),
icon: tmpl.icon ? (
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
) : (
@@ -79,8 +83,13 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
<>
<MenuButton {...menu}>
{(props) => (
<Button {...props} disclosure neutral>
{t("Templates")}
<Button
{...props}
icon={isCompact ? <ShapesIcon /> : undefined}
disclosure={!isCompact}
neutral
>
{isCompact ? undefined : t("Templates")}
</Button>
)}
</MenuButton>
-24
View File
@@ -1,8 +1,6 @@
import { subSeconds } from "date-fns";
import invariant from "invariant";
import uniq from "lodash/uniq";
import { action, computed, observable } from "mobx";
import { now } from "mobx-utils";
import { Pagination } from "@shared/constants";
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
import User from "~/models/User";
@@ -15,17 +13,6 @@ import Relation from "./decorators/Relation";
class Comment extends Model {
static modelName = "Comment";
/**
* Map to keep track of which users are currently typing a reply in this
* comments thread.
*/
@observable
typingUsers: Map<string, Date> = new Map();
@Field
@observable
id: string;
/**
* The Prosemirror data representing the comment content
*/
@@ -107,17 +94,6 @@ class Comment extends Model {
*/
private reactedUsersLoading = false;
/**
* An array of users that are currently typing a reply in this comments thread.
*/
@computed
public get currentlyTypingUsers(): User[] {
return Array.from(this.typingUsers.entries())
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
.map(([userId]) => this.store.rootStore.users.get(userId))
.filter(Boolean) as User[];
}
/**
* Whether the comment is resolved
*/
+4 -5
View File
@@ -65,10 +65,6 @@ export default class Document extends ArchivableModel {
store: DocumentsStore;
@Field
@observable
id: string;
@observable.shallow
data: ProsemirrorData;
@@ -254,7 +250,8 @@ export default class Document extends ArchivableModel {
@computed
get path(): string {
const prefix = this.template ? settingsPath("templates") : "/doc";
const prefix =
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
if (!this.title) {
return `${prefix}/untitled-${this.urlId}`;
@@ -576,6 +573,8 @@ export default class Document extends ArchivableModel {
title?: string;
publish?: boolean;
recursive?: boolean;
collectionId?: string | null;
parentDocumentId?: string;
}) => this.store.duplicate(this, options);
/**
+18 -11
View File
@@ -1,21 +1,29 @@
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
class Event extends Model {
class Event<T extends Model> extends Model {
static modelName = "Event";
id: string;
name: string;
modelId: string | null | undefined;
modelId: string | undefined;
actorIpAddress: string | null | undefined;
documentId: string;
@Relation(() => Document)
document: Document;
collectionId: string | null | undefined;
documentId: string | undefined;
@Relation(() => Collection)
collection: Collection;
collectionId: string | undefined;
@Relation(() => User)
user: User;
@@ -27,13 +35,12 @@ class Event extends Model {
actorId: string;
data: {
name: string;
email: string;
title: string;
published: boolean;
templateId: string;
};
data: Partial<T> | null;
changes: {
attributes: Partial<T>;
previous: Partial<T>;
} | null;
}
export default Event;
-4
View File
@@ -6,10 +6,6 @@ import Field from "./decorators/Field";
class Group extends Model {
static modelName = "Group";
@Field
@observable
id: string;
@Field
@observable
name: string;
-4
View File
@@ -18,10 +18,6 @@ import Relation from "./decorators/Relation";
class Notification extends Model {
static modelName = "Notification";
@Field
@observable
id: string;
/**
* The date the notification was marked as read.
*/
+4
View File
@@ -55,6 +55,10 @@ class Share extends Model {
@observable
url: string;
@Field
@observable
allowIndexing: boolean;
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
-4
View File
@@ -8,10 +8,6 @@ import Field from "./decorators/Field";
class Team extends Model {
static modelName = "Team";
@Field
@observable
id: string;
@Field
@observable
name: string;
-4
View File
@@ -22,10 +22,6 @@ import Field from "./decorators/Field";
class User extends ParanoidModel {
static modelName = "User";
@Field
@observable
id: string;
@Field
@observable
avatarUrl: string;
-4
View File
@@ -5,10 +5,6 @@ import Field from "./decorators/Field";
class WebhookSubscription extends Model {
static modelName = "WebhookSubscription";
@Field
@observable
id: string;
@Field
@observable
name: string;
+12 -9
View File
@@ -27,6 +27,8 @@ import { Bubble } from "./CommentThreadItem";
import { HighlightedText } from "./HighlightText";
type Props = {
/** Callback when the form is submitted. */
onSubmit?: () => void;
/** Callback when the draft should be saved. */
onSaveDraft: (data: ProsemirrorData | undefined) => void;
/** A draft comment for this thread. */
@@ -47,8 +49,6 @@ type Props = {
highlightedText?: string;
/** The text direction of the editor */
dir?: "rtl" | "ltr";
/** Callback when the user is typing in the editor */
onTyping?: () => void;
/** Callback when the editor is focused */
onFocus?: () => void;
/** Callback when the editor is blurred */
@@ -59,8 +59,8 @@ function CommentForm({
documentId,
thread,
draft,
onSubmit,
onSaveDraft,
onTyping,
onFocus,
onBlur,
autoFocus,
@@ -119,6 +119,7 @@ function CommentForm({
documentId,
data: draft,
})
.then(() => onSubmit?.())
.catch(() => {
comment.isNew = true;
toast.error(t("Error creating comment"));
@@ -153,11 +154,14 @@ function CommentForm({
comment.id = uuidv4();
comments.add(comment);
comment.save().catch(() => {
comments.remove(comment.id);
comment.isNew = true;
toast.error(t("Error creating comment"));
});
comment
.save()
.then(() => onSubmit?.())
.catch(() => {
comments.remove(comment.id);
comment.isNew = true;
toast.error(t("Error creating comment"));
});
// optimistically update the comment model
comment.isNew = false;
@@ -175,7 +179,6 @@ function CommentForm({
) => {
const text = value(true, true);
onSaveDraft(text ? value(false, true) : undefined);
onTyping?.();
};
const handleSave = () => {
@@ -1,5 +1,5 @@
import throttle from "lodash/throttle";
import { observer } from "mobx-react";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
@@ -10,14 +10,11 @@ import { s } from "@shared/styles";
import { ProsemirrorData } from "@shared/types";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { Avatar } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Typing from "~/components/Typing";
import { WebsocketContext } from "~/components/WebsocketProvider";
import useCurrentUser from "~/hooks/useCurrentUser";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -40,28 +37,12 @@ type Props = {
enableScroll: () => void;
/** Disable scroll for the comments container */
disableScroll: () => void;
/** Number of replies before collapsing */
collapseThreshold?: number;
/** Number of replies to display when collapsed */
collapseNumDisplayed?: number;
};
function useTypingIndicator({
document,
comment,
}: Pick<Props, "document" | "comment">): [undefined, () => void] {
const socket = React.useContext(WebsocketContext);
const setIsTyping = React.useMemo(
() =>
throttle(() => {
socket?.emit("typing", {
documentId: document.id,
commentId: comment.id,
});
}, 500),
[socket, document.id, comment.id]
);
return [undefined, setIsTyping];
}
function CommentThread({
comment: thread,
document,
@@ -69,23 +50,26 @@ function CommentThread({
focused,
enableScroll,
disableScroll,
collapseThreshold = 5,
collapseNumDisplayed = 3,
}: Props) {
const [focusedOnMount] = React.useState(focused);
const { editor } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
const replyRef = React.useRef<HTMLDivElement>(null);
const user = useCurrentUser();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
const [, setIsTyping] = useTypingIndicator({
document,
comment: thread,
});
const can = usePolicy(document);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document.id}-${thread.id}`,
undefined
);
const canReply = can.comment && !thread.isResolved;
const highlightedCommentMarks = editor
@@ -97,6 +81,17 @@ function CommentThread({
.inThread(thread.id)
.filter((comment) => !comment.isNew);
const [collapse, setCollapse] = React.useState(() => {
const numReplies = commentsInThread.length - 1;
if (numReplies >= collapseThreshold) {
return {
begin: 1,
final: commentsInThread.length - collapseNumDisplayed - 1,
};
}
return null;
});
useOnClickOutside(topRef, (event) => {
if (
focused &&
@@ -111,6 +106,10 @@ function CommentThread({
}
});
const handleSubmit = React.useCallback(() => {
editor?.updateComment(thread.id, { draft: false });
}, [editor, thread.id]);
const handleClickThread = () => {
history.replace({
// Clear any commentId from the URL when explicitly focusing a thread
@@ -120,6 +119,36 @@ function CommentThread({
});
};
const handleClickExpand = (ev: React.SyntheticEvent) => {
ev.stopPropagation();
setCollapse(null);
};
const renderShowMore = (collapse: { begin: number; final: number }) => {
const count = collapse.final - collapse.begin + 1;
const createdBy = commentsInThread
.slice(collapse.begin, collapse.final + 1)
.map((c) => c.createdBy);
const users = Array.from(new Set(createdBy));
const limit = 3;
const overflow = users.length - limit;
return (
<ShowMore onClick={handleClickExpand} key="show-more">
{t("Show {{ count }} reply", { count })}
<Facepile
users={users}
limit={limit}
overflow={overflow}
size={AvatarSize.Medium}
renderAvatar={(item) => (
<Avatar size={AvatarSize.Medium} model={item} />
)}
/>
</ShowMore>
);
};
React.useEffect(() => {
if (!focused && autoFocus) {
setAutoFocus(false);
@@ -174,11 +203,6 @@ function CommentThread({
}
}, [focused, focusedOnMount, thread.id]);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document.id}-${thread.id}`,
undefined
);
return (
<Thread
ref={topRef}
@@ -188,8 +212,17 @@ function CommentThread({
onClick={handleClickThread}
>
{commentsInThread.map((comment, index) => {
if (collapse !== null) {
if (index === collapse.begin) {
return renderShowMore(collapse);
} else if (index > collapse.begin && index <= collapse.final) {
return null;
}
}
const firstOfAuthor =
index === 0 ||
(collapse && index === collapse.final + 1) ||
comment.createdById !== commentsInThread[index - 1].createdById;
const lastOfAuthor =
index === commentsInThread.length - 1 ||
@@ -215,24 +248,15 @@ function CommentThread({
);
})}
{thread.currentlyTypingUsers
.filter((typing) => typing.id !== user.id)
.map((typing) => (
<Flex gap={8} key={typing.id}>
<Avatar model={typing} size={24} />
<Typing />
</Flex>
))}
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
{(focused || draft || commentsInThread.length === 0) && canReply && (
<Fade timing={100}>
<CommentForm
onSubmit={handleSubmit}
onSaveDraft={onSaveDraft}
draft={draft}
documentId={document.id}
thread={thread}
onTyping={setIsTyping}
standalone={commentsInThread.length === 0}
dir={document.dir}
autoFocus={autoFocus}
@@ -271,6 +295,29 @@ const Reply = styled.button`
`}
`;
const ShowMore = styled.div<{ $dir?: "rtl" | "ltr" }>`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1px;
margin-left: ${(props) => (props.$dir === "rtl" ? 0 : 32)}px;
margin-right: ${(props) => (props.$dir !== "rtl" ? 0 : 32)}px;
padding: 8px 12px;
color: ${s("textTertiary")};
background: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
cursor: var(--pointer);
font-size: 13px;
&: ${hover} {
color: ${s("textSecondary")};
background: ${s("backgroundTertiary")};
}
* {
border-color: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
}
`;
const Thread = styled.div<{
$focused: boolean;
$recessed: boolean;
@@ -1,6 +1,7 @@
import { differenceInMilliseconds } from "date-fns";
import { action } from "mobx";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -16,10 +17,14 @@ import Comment from "~/models/Comment";
import { Avatar } from "~/components/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import ReactionList from "~/components/Reactions/ReactionList";
import ReactionPicker from "~/components/Reactions/ReactionPicker";
import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import { resolveCommentFactory } from "~/actions/definitions/comments";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
@@ -242,11 +247,13 @@ function CommentThreadItem({
onRemoveReaction={handleRemoveReaction}
picker={
!comment.isResolved ? (
<StyledReactionPicker
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
size={28}
rounded
/>
) : undefined
}
@@ -258,13 +265,21 @@ function CommentThreadItem({
{!isEditing && (
<Actions gap={4} dir={dir}>
{!comment.isResolved && (
<StyledReactionPicker
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
/>
<>
{firstOfThread && (
<ResolveButton onUpdate={handleUpdate} comment={comment} />
)}
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
rounded
/>
</>
)}
<StyledMenu
<Action
as={CommentMenu}
comment={comment}
onEdit={setEditing}
onDelete={handleDelete}
@@ -278,6 +293,38 @@ function CommentThreadItem({
);
}
const ResolveButton = ({
comment,
onUpdate,
}: {
comment: Comment;
onUpdate: (attrs: { resolved: boolean }) => void;
}) => {
const context = useActionContext();
const { t } = useTranslation();
return (
<Tooltip
content={t("Mark as resolved")}
placement="top"
delay={500}
hideOnClick
>
<Action
as={NudeButton}
context={context}
action={resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
})}
rounded
>
<DoneIcon size={22} outline />
</Action>
</Tooltip>
);
};
const StyledCommentEditor = styled(CommentEditor)`
${(props) =>
!props.readOnly &&
@@ -308,25 +355,13 @@ const Body = styled.form`
border-radius: 2px;
`;
const StyledMenu = styled(CommentMenu)`
color: ${s("textSecondary")};
svg {
fill: currentColor;
opacity: 0.5;
}
&: ${hover}, &[aria-expanded= "true"] {
background: ${s("backgroundQuaternary")};
svg {
opacity: 0.75;
}
}
`;
const StyledReactionPicker = styled(ReactionPicker)`
const Action = styled.span<{ rounded?: boolean }>`
color: ${s("textSecondary")};
${(props) =>
props.rounded &&
css`
border-radius: 50%;
`}
svg {
fill: currentColor;
@@ -352,7 +387,7 @@ const Actions = styled(Flex)<{ dir?: "rtl" | "ltr" }>`
background: ${s("backgroundSecondary")};
padding-left: 4px;
&:has(${StyledReactionPicker}[aria-expanded="true"], ${StyledMenu}[aria-expanded="true"]) {
&:has(${Action}[aria-expanded="true"]) {
opacity: 1;
}
`;
@@ -386,7 +421,7 @@ export const Bubble = styled(Flex)<{
min-width: 2em;
margin-bottom: 1px;
padding: 8px 12px;
transition: color 100ms ease-out, ${s("backgroundTransition")};
transition: color 100ms ease-out, background 100ms ease-out;
${({ $lastOfThread, $canReply }) =>
$lastOfThread &&
+2 -2
View File
@@ -44,7 +44,7 @@ function Comments() {
const isAtBottom = React.useRef(true);
const [showJumpToRecentBtn, setShowJumpToRecentBtn] = React.useState(false);
useKeyDown("Escape", () => document && ui.collapseComments(document?.id));
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document?.id}-new`,
@@ -126,7 +126,7 @@ function Comments() {
<CommentSortMenu />
</Flex>
}
onClose={() => ui.collapseComments(document?.id)}
onClose={() => ui.set({ commentsExpanded: false })}
scrollable={false}
>
<Scrollable
@@ -87,7 +87,6 @@ const StickyWrapper = styled.div`
border-radius: 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
@@ -218,11 +218,11 @@ function DataLoader({ match, children }: Props) {
);
}
const readOnly =
!isEditing || !can.update || document.isArchived || !!revisionId;
const canEdit = can.update && !document.isArchived && !revisionId;
const readOnly = !isEditing || !canEdit;
return (
<React.Fragment key={readOnly ? "readOnly" : ""}>
<React.Fragment key={canEdit ? "edit" : "read"}>
{children({
document,
revision,
+18 -10
View File
@@ -26,6 +26,7 @@ import {
TeamPreference,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import { parseDomain } from "@shared/utils/domains";
import { determineIconType } from "@shared/utils/icon";
import RootStore from "~/stores/RootStore";
@@ -44,7 +45,6 @@ import withStores from "~/components/withStores";
import type { Editor as TEditor } from "~/editor";
import { SearchResult } from "~/editor/components/LinkEditor";
import { client } from "~/utils/ApiClient";
import { replaceTitleVariables } from "~/utils/date";
import { emojiToUrl } from "~/utils/emoji";
import { isModKey } from "~/utils/keyboard";
@@ -151,7 +151,13 @@ class DocumentScene extends React.Component<Props> {
}
const { view, schema } = editorRef;
const doc = Node.fromJSON(schema, template.data);
const doc = Node.fromJSON(
schema,
ProsemirrorHelper.replaceTemplateVariables(
template.data,
this.props.auth.user!
)
);
if (doc) {
view.dispatch(
@@ -168,9 +174,9 @@ class DocumentScene extends React.Component<Props> {
}
if (!this.title) {
const title = replaceTitleVariables(
const title = TextHelper.replaceTemplateVariables(
template.title,
this.props.auth.user || undefined
this.props.auth.user!
);
this.title = title;
this.props.document.title = title;
@@ -215,13 +221,15 @@ class DocumentScene extends React.Component<Props> {
onUndoRedo = (event: KeyboardEvent) => {
if (isModKey(event)) {
event.preventDefault();
if (event.shiftKey) {
if (this.editor.current?.redo()) {
event.preventDefault();
if (!this.props.readOnly) {
this.editor.current?.commands.redo();
}
} else {
if (this.editor.current?.undo()) {
event.preventDefault();
if (!this.props.readOnly) {
this.editor.current?.commands.undo();
}
}
}
@@ -410,7 +418,8 @@ class DocumentScene extends React.Component<Props> {
(team && team.documentEmbeds === false) || document.embedsDisabled;
const showContents =
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
const tocPos =
tocPosition ??
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
@@ -695,7 +704,6 @@ const Footer = styled.div`
const Background = styled(Container)`
position: relative;
background: ${s("background")};
transition: ${s("backgroundTransition")};
`;
const ReferencesWrapper = styled.div`
@@ -46,7 +46,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
&nbsp;&nbsp;
<CommentLink
to={documentPath(document)}
onClick={() => ui.toggleComments(document.id)}
onClick={() => ui.toggleComments()}
>
<CommentIcon size={18} />
{commentsCount
+15 -2
View File
@@ -27,6 +27,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import {
documentHistoryPath,
@@ -81,6 +82,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const user = useCurrentUser({ rejectOnEmpty: false });
const team = useCurrentTeam({ rejectOnEmpty: false });
const history = useHistory();
const params = useQuery();
const {
document,
onChangeTitle,
@@ -103,9 +105,20 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
React.useEffect(() => {
if (focusedComment) {
ui.expandComments(document.id);
const viewingResolved = params.get("resolved") === "";
if (
(focusedComment.isResolved && !viewingResolved) ||
(!focusedComment.isResolved && viewingResolved)
) {
history.replace({
search: focusedComment.isResolved ? "resolved=" : "",
pathname: location.pathname,
state: { commentId: focusedComment.id },
});
}
ui.set({ commentsExpanded: true });
}
}, [focusedComment, ui, document.id]);
}, [focusedComment, ui, document.id, history, params]);
// Save document when blurring title, but delay so that if clicking on a
// button this is allowed to execute first.
+28 -10
View File
@@ -30,6 +30,7 @@ import { publishDocument } from "~/actions/definitions/documents";
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
import { restoreRevision } from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import useComponentSize from "~/hooks/useComponentSize";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useEditingFocus from "~/hooks/useEditingFocus";
@@ -86,11 +87,13 @@ function DocumentHeader({
const team = useCurrentTeam({ rejectOnEmpty: false });
const user = useCurrentUser({ rejectOnEmpty: false });
const { resolvedTheme } = ui;
const isMobile = useMobile();
const isMobileMedia = useMobile();
const isRevision = !!revision;
const isEditingFocus = useEditingFocus();
const { editor } = useDocumentContext();
const { hasHeadings } = useDocumentContext();
const { hasHeadings, editor } = useDocumentContext();
const ref = React.useRef<HTMLDivElement | null>(null);
const size = useComponentSize(ref);
const isMobile = isMobileMedia || size.width < 700;
// We cache this value for as long as the component is mounted so that if you
// apply a template there is still the option to replace it until the user
@@ -103,6 +106,10 @@ function DocumentHeader({
});
}, [onSave]);
const handleToggle = React.useCallback(() => {
ui.set({ tocVisible: !ui.tocVisible });
}, [ui]);
const context = useActionContext({
activeDocumentId: document?.id,
});
@@ -113,7 +120,8 @@ function DocumentHeader({
const canToggleEmbeds = team?.documentEmbeds;
const isShare = !!shareId;
const showContents =
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
const toc = (
<Tooltip
@@ -129,7 +137,7 @@ function DocumentHeader({
placement="bottom"
>
<Button
onClick={showContents ? ui.hideTableOfContents : ui.showTableOfContents}
onClick={handleToggle}
icon={<TableOfContentsIcon />}
borderOnHover
neutral
@@ -180,7 +188,7 @@ function DocumentHeader({
useKeyDown(
(event) => event.ctrlKey && event.altKey && event.key === "˙",
ui.tocVisible ? ui.hideTableOfContents : ui.showTableOfContents,
handleToggle,
{
allowInInput: true,
}
@@ -225,6 +233,7 @@ function DocumentHeader({
return (
<>
<StyledHeader
ref={ref}
$hidden={isEditingFocus}
hasSidebar
left={
@@ -232,7 +241,11 @@ function DocumentHeader({
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document}>
{toc} <Star document={document} color={theme.textSecondary} />
{document.isTemplate ? null : (
<>
{toc} <Star document={document} color={theme.textSecondary} />
</>
)}
</DocumentBreadcrumb>
)
}
@@ -245,7 +258,7 @@ function DocumentHeader({
{document.isArchived && <Badge>{t("Archived")}</Badge>}
</Flex>
}
actions={
actions={({ isCompact }) => (
<>
<ObservingBanner />
@@ -253,11 +266,15 @@ function DocumentHeader({
<Status>{t("Saving")}</Status>
)}
{!isDeleted && !isRevision && can.listViews && (
<Collaborators document={document} />
<Collaborators
document={document}
limit={isCompact ? 3 : undefined}
/>
)}
{(isEditing || !user?.separateEditMode) && !isTemplate && isNew && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document}
onSelectTemplate={onSelectTemplate}
/>
@@ -297,6 +314,7 @@ function DocumentHeader({
{can.update &&
can.createChildDocument &&
!isRevision &&
!isCompact &&
!isMobile && (
<Action>
<NewChildDocumentMenu
@@ -368,7 +386,7 @@ function DocumentHeader({
/>
</Action>
</>
}
)}
/>
</>
);
+2 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Empty from "~/components/Empty";
import PaginatedEventList from "~/components/PaginatedEventList";
@@ -12,7 +13,7 @@ import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
const EMPTY_ARRAY: Event[] = [];
const EMPTY_ARRAY: Event<Document>[] = [];
function History() {
const { events, documents } = useStores();
@@ -273,6 +273,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
<>
{showCache && (
<Editor
editorStyle={props.editorStyle}
embedsDisabled={props.embedsDisabled}
defaultValue={props.defaultValue}
extensions={props.extensions}
@@ -290,8 +291,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
style={
showCache
? {
height: 0,
opacity: 0,
pointerEvents: "none",
}
: undefined
}
@@ -61,6 +61,8 @@ function ReferenceListItem({
}: Props) {
const { icon, color } = document;
const isEmoji = determineIconType(icon) === IconType.Emoji;
const title =
document instanceof Document ? document.titleWithDefault : document.title;
return (
<DocumentLink
@@ -81,9 +83,7 @@ function ReferenceListItem({
) : (
<DocumentIcon />
)}
<Title>
{isEmoji ? document.title.replace(icon!, "") : document.title}
</Title>
<Title>{isEmoji ? title.replace(icon!, "") : title}</Title>
</Content>
</DocumentLink>
);
@@ -31,11 +31,13 @@ function References({ document }: Props) {
: [];
const showBacklinks = !!backlinks.length;
const showChildDocuments = !!children.length;
const shouldFade = React.useRef(!showBacklinks && !showChildDocuments);
const isBacklinksTab = location.hash === "#backlinks" || !showChildDocuments;
const height = Math.max(backlinks.length, children.length) * 40;
const Component = shouldFade.current ? Fade : React.Fragment;
return showBacklinks || showChildDocuments ? (
<Fade>
<Component>
<Tabs>
{showChildDocuments && (
<Tab to="#children" isActive={() => !isBacklinksTab}>
@@ -80,7 +82,7 @@ function References({ document }: Props) {
</List>
)}
</Content>
</Fade>
</Component>
) : null;
}
+15 -3
View File
@@ -28,12 +28,24 @@ function DocumentMove({ document }: Props) {
);
const items = React.useMemo(() => {
// Recursively filter out the document itself and its existing parent doc, if any.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
.map(filterSourceDocument),
});
// Filter out the document itself and its existing parent doc, if any.
const nodes = flatten(collectionTrees.map(flattenTree))
.filter(
(node) =>
node.id !== document.id && node.id !== document.parentDocumentId
)
.map(filterSourceDocument)
// Filter out collections that we don't have permission to create documents in.
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
@@ -108,21 +120,21 @@ function DocumentMove({ document }: Props) {
);
}
const FlexContainer = styled(Flex)`
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
const Footer = styled(Flex)`
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
const StyledText = styled(Text)`
export const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
-1
View File
@@ -118,7 +118,6 @@ function Home() {
const Documents = styled.div`
position: relative;
background: ${s("background")};
transition: ${s("backgroundTransition")};
`;
export default observer(Home);
+1 -1
View File
@@ -127,7 +127,7 @@ function Invite({ onSubmit }: Props) {
<Trans>{{ collectionCount }} collections</Trans>
</strong>
</Tooltip>
.
.{" "}
</span>
) : undefined;
+119 -101
View File
@@ -5,6 +5,124 @@ import { Trans } from "react-i18next";
import Notice from "~/components/Notice";
import useQuery from "~/hooks/useQuery";
function Message({ notice }: { notice: string }) {
switch (notice) {
case "domain-not-allowed":
return (
<Trans>
The domain associated with your email address has not been allowed for
this workspace.
</Trans>
);
case "domain-required":
return (
<Trans>
Unable to sign-in. Please navigate to your workspace's custom URL,
then try to sign-in again.
<hr />
If you were invited to a workspace, you will find a link to it in the
invite email.
</Trans>
);
case "gmail-account-creation":
return (
<Trans>
Sorry, a new account cannot be created with a personal Gmail address.
<hr />
Please use a Google Workspaces account instead.
</Trans>
);
case "pending-deletion":
return (
<Trans>
The workspace associated with your user is scheduled for deletion and
cannot be accessed at this time.
</Trans>
);
case "maximum-reached":
return (
<Trans>
The workspace you authenticated with is not authorized on this
installation. Try another?
</Trans>
);
case "malformed-user-info":
return (
<Trans>
We could not read the user info supplied by your identity provider.
</Trans>
);
case "email-auth-required":
return (
<Trans>
Your account uses email sign-in, please sign-in with email to
continue.
</Trans>
);
case "email-auth-ratelimit":
return (
<Trans>
An email sign-in link was recently sent, please check your inbox or
try again in a few minutes.
</Trans>
);
case "auth-error":
case "state-mismatch":
return (
<Trans>
Authentication failed we were unable to sign you in at this time.
Please try again.
</Trans>
);
case "invalid-authentication":
return (
<Trans>
Authentication failed you do not have permission to access this
workspace.
</Trans>
);
case "expired-token":
return (
<Trans>
Sorry, it looks like that sign-in link is no longer valid, please try
requesting another.
</Trans>
);
case "user-suspended":
return (
<Trans>
Your account has been suspended. To re-activate your account, please
contact a workspace admin.
</Trans>
);
case "team-suspended":
return (
<Trans>
This workspace has been suspended. Please contact support to restore
access.
</Trans>
);
case "authentication-provider-disabled":
return (
<Trans>
Authentication failed this login method was disabled by a team
admin.
</Trans>
);
case "invite-required":
return (
<Trans>
The workspace you are trying to join requires an invite before you can
create an account.
<hr />
Please request an invite from your workspace admin and try again.
</Trans>
);
default:
return <Trans>Sorry, an unknown error occurred.</Trans>;
}
}
export default function Notices() {
const query = useQuery();
const notice = query.get("notice");
@@ -15,107 +133,7 @@ export default function Notices() {
return (
<Notice icon={<WarningIcon color="currentcolor" />}>
{notice === "domain-not-allowed" && (
<Trans>
The domain associated with your email address has not been allowed for
this workspace.
</Trans>
)}
{notice === "domain-required" && (
<Trans>
Unable to sign-in. Please navigate to your workspace's custom URL,
then try to sign-in again.
<hr />
If you were invited to a workspace, you will find a link to it in the
invite email.
</Trans>
)}
{notice === "gmail-account-creation" && (
<Trans>
Sorry, a new account cannot be created with a personal Gmail address.
<hr />
Please use a Google Workspaces account instead.
</Trans>
)}
{notice === "pending-deletion" && (
<Trans>
The workspace associated with your user is scheduled for deletion and
cannot be accessed at this time.
</Trans>
)}
{notice === "maximum-reached" && (
<Trans>
The workspace you authenticated with is not authorized on this
installation. Try another?
</Trans>
)}
{notice === "malformed-user-info" && (
<Trans>
We could not read the user info supplied by your identity provider.
</Trans>
)}
{notice === "email-auth-required" && (
<Trans>
Your account uses email sign-in, please sign-in with email to
continue.
</Trans>
)}
{notice === "email-auth-ratelimit" && (
<Trans>
An email sign-in link was recently sent, please check your inbox or
try again in a few minutes.
</Trans>
)}
{(notice === "auth-error" || notice === "state-mismatch") && (
<Trans>
Authentication failed we were unable to sign you in at this time.
Please try again.
</Trans>
)}
{notice === "invalid-authentication" && (
<Trans>
Authentication failed you do not have permission to access this
workspace.
</Trans>
)}
{notice === "expired-token" && (
<Trans>
Sorry, it looks like that sign-in link is no longer valid, please try
requesting another.
</Trans>
)}
{notice === "user-suspended" && (
<Trans>
Your account has been suspended. To re-activate your account, please
contact a workspace admin.
</Trans>
)}
{notice === "team-suspended" && (
<Trans>
This workspace has been suspended. Please contact support to restore
access.
</Trans>
)}
{notice === "authentication-provider-disabled" && (
<Trans>
Authentication failed this login method was disabled by a team
admin.
</Trans>
)}
{notice === "invite-required" && (
<Trans>
The workspace you are trying to join requires an invite before you can
create an account.
<hr />
Please request an invite from your workspace admin and try again.
</Trans>
)}
{notice === "domain-not-allowed" && (
<Trans>
Sorry, your domain is not allowed. Please try again with an allowed
workspace domain.
</Trans>
)}
<Message notice={notice} />
</Notice>
);
}
+2 -1
View File
@@ -231,7 +231,8 @@ function Login({ children }: Props) {
config.providers.length === 1 &&
config.providers[0].id === "oidc" &&
!env.OIDC_DISABLE_REDIRECT &&
!query.get("notice")
!query.get("notice") &&
!query.get("logout")
) {
window.location.href = getRedirectUrl(config.providers[0].authUrl);
return null;
+2 -1
View File
@@ -2,6 +2,7 @@ import * as React from "react";
import { Redirect } from "react-router-dom";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { logoutPath } from "~/utils/routeHelpers";
const Logout = () => {
const { auth } = useStores();
@@ -17,7 +18,7 @@ const Logout = () => {
if (env.OIDC_LOGOUT_URI) {
return null; // user will be redirected to logout URI after logout
}
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
export default Logout;
@@ -59,7 +59,6 @@ const StyledInput = styled.input`
outline: none;
border: 0;
background: ${s("sidebarBackground")};
transition: ${s("backgroundTransition")};
border-radius: 4px;
color: ${s("text")};
+6 -33
View File
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import ApiKey from "~/models/ApiKey";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
@@ -13,36 +12,17 @@ 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);
const context = useActionContext();
const [copiedKeyId, setCopiedKeyId] = React.useState<string | null>();
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
const handleCopy = React.useCallback(
(keyId: string) => {
if (copyTimeoutIdRef.current) {
clearTimeout(copyTimeoutIdRef.current);
}
setCopiedKeyId(keyId);
copyTimeoutIdRef.current = setTimeout(() => {
setCopiedKeyId(null);
}, 3000);
toast.message(t("API key copied to clipboard"));
},
[t]
);
return (
<Scene
title={t("API")}
@@ -62,12 +42,11 @@ function ApiKeys() {
</>
}
>
<Heading>{t("API")}</Heading>
<Heading>{t("API Keys")}</Heading>
<Text as="p" type="secondary">
<Trans
defaults="Create personal API keys to authenticate with the API and programatically control
your workspace's data. API keys have the same permissions as your user account.
For more details see the <em>developer documentation</em>."
defaults="API keys can be used to authenticate with the API and programatically control
your workspace's data. For more details see the <em>developer documentation</em>."
components={{
em: (
<a
@@ -81,16 +60,10 @@ function ApiKeys() {
</Text>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
items={apiKeys.orderedData}
heading={<h2>{t("All")}</h2>}
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
isCopied={apiKey.id === copiedKeyId}
onCopy={handleCopy}
/>
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
</Scene>
+77
View File
@@ -0,0 +1,77 @@
import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import ApiKey from "~/models/ApiKey";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
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 PersonalApiKeys() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const { apiKeys } = useStores();
const can = usePolicy(team);
const context = useActionContext();
return (
<Scene
title={t("API")}
icon={<CodeIcon />}
actions={
<>
{can.createApiKey && (
<Action>
<Button
type="submit"
value={`${t("New API key")}`}
action={createApiKey}
context={context}
/>
</Action>
)}
</>
}
>
<Heading>{t("API")}</Heading>
<Text as="p" type="secondary">
<Trans
defaults="Create personal API keys to authenticate with the API and programatically control
your workspace's data. API keys have the same permissions as your user account.
For more details see the <em>developer documentation</em>."
components={{
em: (
<a
href="https://www.getoutline.com/developers"
target="_blank"
rel="noreferrer"
/>
),
}}
/>
</Text>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
</Scene>
);
}
export default observer(PersonalApiKeys);
+2 -3
View File
@@ -220,9 +220,6 @@ function Security() {
</SettingRow>
)}
{!data.inviteRequired && (
<DomainManagement onSuccess={showSuccessMessage} />
)}
{!data.inviteRequired && (
<SettingRow
label={t("Default role")}
@@ -252,6 +249,8 @@ function Security() {
</SettingRow>
)}
<DomainManagement onSuccess={showSuccessMessage} />
<h2>{t("Behavior")}</h2>
<SettingRow
label={t("Public document sharing")}
@@ -12,7 +12,6 @@ export const ActionRow = styled.div`
margin: 0 -50vw;
background: ${s("background")};
transition: ${s("backgroundTransition")};
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
@@ -1,6 +1,8 @@
import { observer } from "mobx-react";
import { CopyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import ApiKey from "~/models/ApiKey";
import Button from "~/components/Button";
import CopyToClipboard from "~/components/CopyToClipboard";
@@ -8,24 +10,29 @@ import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useUserLocale from "~/hooks/useUserLocale";
import ApiKeyMenu from "~/menus/ApiKeyMenu";
import { dateToExpiry } from "~/utils/date";
type Props = {
/** The API key to display */
apiKey: ApiKey;
isCopied: boolean;
onCopy: (keyId: string) => void;
};
const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
const ApiKeyListItem = ({ apiKey }: Props) => {
const { t } = useTranslation();
const userLocale = useUserLocale();
const user = useCurrentUser();
const subtitle = (
<>
<Text type="tertiary">
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix /> &middot;{" "}
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
{apiKey.userId === user.id
? ""
: t(`by {{ name }}`, { name: user.name })}{" "}
&middot;{" "}
</Text>
{apiKey.lastActiveAt && (
<Text type={"tertiary"}>
@@ -41,9 +48,19 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
</>
);
const [copied, setCopied] = React.useState<boolean>(false);
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
const handleCopy = React.useCallback(() => {
onCopy(apiKey.id);
}, [apiKey.id, onCopy]);
if (copyTimeoutIdRef.current) {
clearTimeout(copyTimeoutIdRef.current);
}
setCopied(true);
copyTimeoutIdRef.current = setTimeout(() => {
setCopied(false);
}, 3000);
toast.message(t("API key copied to clipboard"));
}, [t]);
return (
<ListItem
@@ -52,10 +69,10 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
subtitle={subtitle}
actions={
<Flex align="center" gap={8}>
{apiKey.value && (
{apiKey.value && handleCopy && (
<CopyToClipboard text={apiKey.value} onCopy={handleCopy}>
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
{isCopied ? t("Copied") : t("Copy")}
{copied ? t("Copied") : t("Copy")}
</Button>
</CopyToClipboard>
)}
@@ -74,4 +91,4 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
);
};
export default ApiKeyListItem;
export default observer(ApiKeyListItem);
@@ -5,7 +5,6 @@ import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import NudeButton from "~/components/NudeButton";
@@ -110,29 +109,25 @@ function DomainManagement({ onSuccess }: Props) {
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
{!allowedDomains.length ||
allowedDomains[allowedDomains.length - 1] !== "" ? (
<Fade>
<Button type="button" onClick={handleAddDomain} neutral>
{allowedDomains.length ? (
<Trans>Add another</Trans>
) : (
<Trans>Add a domain</Trans>
)}
</Button>
</Fade>
<Button type="button" onClick={handleAddDomain} neutral>
{allowedDomains.length ? (
<Trans>Add another</Trans>
) : (
<Trans>Add a domain</Trans>
)}
</Button>
) : (
<span />
)}
{showSaveChanges && (
<Fade>
<Button
type="button"
onClick={handleSaveDomains}
disabled={team.isSaving}
>
<Trans>Save changes</Trans>
</Button>
</Fade>
<Button
type="button"
onClick={handleSaveDomains}
disabled={team.isSaving}
>
<Trans>Save changes</Trans>
</Button>
)}
</Flex>
</SettingRow>
@@ -52,7 +52,7 @@ function PeopleTable({ canManage, ...rest }: Props) {
),
},
{
id: "isAdmin",
id: "role",
Header: t("Role"),
accessor: "rank",
Cell: observer(({ row }: { row: { original: User } }) => (
+9 -20
View File
@@ -7,31 +7,19 @@ import { CustomTheme } from "@shared/types";
import Storage from "@shared/utils/Storage";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import RootStore from "~/stores/RootStore";
import Policy from "~/models/Policy";
import Team from "~/models/Team";
import User from "~/models/User";
import env from "~/env";
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
import { PartialExcept } from "~/types";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
import Logger from "~/utils/Logger";
import isCloudHosted from "~/utils/isCloudHosted";
import Store from "./base/Store";
type PersistedData = {
user?: PartialExcept<User, "id">;
team?: PartialExcept<Team, "id">;
collaborationToken?: string;
availableTeams?: {
id: string;
name: string;
avatarUrl: string;
url: string;
isSignedIn: boolean;
}[];
policies?: Policy[];
};
type PersistedData = Pick<
AuthStore,
"user" | "team" | "collaborationToken" | "availableTeams" | "policies"
>;
type Provider = {
id: string;
@@ -165,9 +153,10 @@ export default class AuthStore extends Store<Team> {
/** The current team's policies */
@computed
get policies() {
return this.currentTeamId
? [this.rootStore.policies.get(this.currentTeamId)]
: [];
const policy = this.currentTeamId
? this.rootStore.policies.get(this.currentTeamId)
: undefined;
return policy ? [policy] : [];
}
/** Whether the user is signed in */
@@ -177,7 +166,7 @@ export default class AuthStore extends Store<Team> {
}
@computed
get asJson() {
get asJson(): PersistedData {
return {
user: this.user,
team: this.team,
-14
View File
@@ -150,20 +150,6 @@ export default class CommentsStore extends Store<Comment> {
return this.data.get(res.data.id) as Comment;
};
@action
setTyping({
commentId,
userId,
}: {
commentId: string;
userId: string;
}): void {
const comment = this.get(commentId);
if (comment) {
comment.typingUsers.set(userId, new Date());
}
}
@computed
get orderedData(): Comment[] {
return orderBy(Array.from(this.data.values()), "createdAt", "asc");
+10 -6
View File
@@ -68,12 +68,16 @@ export default class PresenceStore {
@action
private update(documentId: string, userId: string, isEditing: boolean) {
const existing = this.data.get(documentId) || new Map();
existing.set(userId, {
isEditing,
userId,
});
this.data.set(documentId, existing);
const presence = this.data.get(documentId) || new Map();
const existing = presence.get(userId);
if (!existing || existing.isEditing !== isEditing) {
presence.set(userId, {
isEditing,
userId,
});
this.data.set(documentId, presence);
}
}
public get(documentId: string): DocumentPresence | null | undefined {
+4
View File
@@ -368,6 +368,10 @@ export default class DocumentsStore extends Store<Document> {
fetchTemplates = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("list", { ...options, template: true });
@action
fetchAllTemplates = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchAll({ ...options, template: true });
@action
fetchAlphabetical = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("list", {
+2 -2
View File
@@ -4,7 +4,7 @@ import Event from "~/models/Event";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
export default class EventsStore extends Store<Event> {
export default class EventsStore extends Store<Event<any>> {
actions = [RPCAction.List];
constructor(rootStore: RootStore) {
@@ -12,7 +12,7 @@ export default class EventsStore extends Store<Event> {
}
@computed
get orderedData(): Event[] {
get orderedData(): Event<any>[] {
return orderBy(Array.from(this.data.values()), "createdAt", "desc");
}
}
+28 -61
View File
@@ -1,4 +1,4 @@
import { action, autorun, computed, observable } from "mobx";
import { action, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
import Storage from "@shared/utils/Storage";
@@ -23,15 +23,16 @@ export enum SystemTheme {
Dark = "dark",
}
type PersistedData = {
languagePromptDismissed: boolean | undefined;
theme: Theme;
sidebarCollapsed: boolean;
sidebarWidth: number;
sidebarRightWidth: number;
tocVisible: boolean | undefined;
commentsExpanded: string[];
};
type PersistedData = Pick<
UiStore,
| "languagePromptDismissed"
| "commentsExpanded"
| "theme"
| "sidebarWidth"
| "sidebarRightWidth"
| "sidebarCollapsed"
| "tocVisible"
>;
class UiStore {
// has the user seen the prompt to change the UI language and actioned it
@@ -74,7 +75,7 @@ class UiStore {
sidebarCollapsed = false;
@observable
commentsExpanded: string[] = [];
commentsExpanded = false;
@observable
sidebarIsResizing = false;
@@ -98,7 +99,7 @@ class UiStore {
this.sidebarRightWidth =
data.sidebarRightWidth || defaultTheme.sidebarRightWidth;
this.tocVisible = data.tocVisible;
this.commentsExpanded = data.commentsExpanded || [];
this.commentsExpanded = !!data.commentsExpanded;
this.theme = data.theme || Theme.System;
// system theme listeners
@@ -134,10 +135,6 @@ class UiStore {
this.tocVisible = newData.tocVisible;
}
});
autorun(() => {
Storage.set(UI_STORE, this.asJson);
});
}
@action
@@ -145,14 +142,9 @@ class UiStore {
startViewTransition(() => {
flushSync(() => {
this.theme = theme;
this.persist();
});
});
Storage.set("theme", this.theme);
};
@action
setLanguagePromptDismissed = () => {
this.languagePromptDismissed = true;
};
@action
@@ -205,64 +197,35 @@ class UiStore {
this.activeCollectionId = undefined;
};
@action
setSidebarWidth = (width: number): void => {
this.sidebarWidth = width;
};
@action
setRightSidebarWidth = (width: number): void => {
this.sidebarRightWidth = width;
};
@action
collapseSidebar = () => {
this.sidebarCollapsed = true;
this.set({ sidebarCollapsed: true });
};
@action
expandSidebar = () => {
sidebarHidden = false;
this.sidebarCollapsed = false;
this.set({ sidebarCollapsed: false });
};
@action
collapseComments = (documentId: string) => {
this.commentsExpanded = this.commentsExpanded.filter(
(id) => id !== documentId
);
};
@action
expandComments = (documentId: string) => {
if (!this.commentsExpanded.includes(documentId)) {
this.commentsExpanded.push(documentId);
set = (data: Partial<PersistedData>) => {
for (const key in data) {
// @ts-expect-error doesn't understand PersistedData is subset of keys
this[key] = data[key];
}
this.persist();
};
@action
toggleComments = (documentId: string) => {
if (this.commentsExpanded.includes(documentId)) {
this.collapseComments(documentId);
} else {
this.expandComments(documentId);
}
toggleComments = () => {
this.set({ commentsExpanded: !this.commentsExpanded });
};
@action
toggleCollapsedSidebar = () => {
sidebarHidden = false;
this.sidebarCollapsed = !this.sidebarCollapsed;
};
@action
showTableOfContents = () => {
this.tocVisible = true;
};
@action
hideTableOfContents = () => {
this.tocVisible = false;
this.set({ sidebarCollapsed: !this.sidebarCollapsed });
};
@action
@@ -324,6 +287,10 @@ class UiStore {
theme: this.theme,
};
}
private persist = () => {
Storage.set(UI_STORE, this.asJson);
};
}
export default UiStore;
-1
View File
@@ -124,7 +124,6 @@ declare module "styled-components" {
backgroundSecondary: string;
backgroundTertiary: string;
backgroundQuaternary: string;
backgroundTransition: string;
accent: string;
accentText: string;
link: string;
+1 -28
View File
@@ -10,16 +10,7 @@ import {
isPast,
} from "date-fns";
import { TFunction } from "i18next";
import startCase from "lodash/startCase";
import {
getCurrentDateAsString,
getCurrentDateTimeAsString,
getCurrentTimeAsString,
unicodeCLDRtoBCP47,
dateLocale,
locales,
} from "@shared/utils/date";
import User from "~/models/User";
import { dateLocale, locales } from "@shared/utils/date";
export function dateToHeading(
dateTime: string,
@@ -121,21 +112,3 @@ export function dateToExpiry(
date: formatDate(date, "MMM dd, yyyy", { locale }),
});
}
/**
* Replaces template variables in the given text with the current date and time.
*
* @param text The text to replace the variables in
* @param user The user to get the language/locale from
* @returns The text with the variables replaced
*/
export function replaceTitleVariables(text: string, user?: User) {
const locales = user?.language
? unicodeCLDRtoBCP47(user.language)
: undefined;
return text
.replace("{date}", startCase(getCurrentDateAsString(locales)))
.replace("{time}", startCase(getCurrentTimeAsString(locales)))
.replace("{datetime}", startCase(getCurrentDateTimeAsString(locales)));
}
+7
View File
@@ -8,6 +8,13 @@ export function homePath(): string {
return env.ROOT_SHARE_ID ? "/" : "/home";
}
export function logoutPath() {
return {
pathname: "/",
search: "logout=true",
};
}
export function draftsPath(): string {
return "/drafts";
}
+20 -19
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.616.0",
"@aws-sdk/lib-storage": "3.616.0",
"@aws-sdk/s3-presigned-post": "3.616.0",
"@aws-sdk/s3-request-presigner": "3.616.0",
"@aws-sdk/signature-v4-crt": "^3.616.0",
"@aws-sdk/client-s3": "3.693.0",
"@aws-sdk/lib-storage": "3.693.0",
"@aws-sdk/s3-presigned-post": "3.693.0",
"@aws-sdk/s3-request-presigner": "3.693.0",
"@aws-sdk/signature-v4-crt": "^3.693.0",
"@babel/core": "^7.24.7",
"@babel/plugin-proposal-decorators": "^7.24.7",
"@babel/plugin-transform-class-properties": "^7.24.7",
@@ -79,11 +79,11 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@octokit/auth-app": "^6.1.2",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.117.0",
"@sentry/node": "^7.119.0",
"@sentry/react": "^7.119.0",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.0",
@@ -150,7 +150,7 @@
"markdown-it": "^13.0.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
"mermaid": "9.3.0",
"mermaid": "11.4.0",
"mime-types": "^2.1.35",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
@@ -184,7 +184,7 @@
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.4.0",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.34.3",
"prosemirror-view": "^1.36.0",
"query-string": "^7.1.3",
"randomstring": "1.3.0",
"rate-limiter-flexible": "^2.4.2",
@@ -208,6 +208,7 @@
"react-waypoint": "^10.3.0",
"react-window": "^1.8.10",
"reakit": "^1.3.11",
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.2.2",
"refractor": "^3.6.0",
"request-filtering-agent": "^1.1.2",
@@ -239,7 +240,7 @@
"utility-types": "^3.10.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vite": "^5.4.10",
"vite": "^5.4.11",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.13.0",
"ws": "^7.5.10",
@@ -248,13 +249,13 @@
"y-protocols": "^1.0.6",
"yauzl": "^2.10.0",
"yjs": "^13.6.1",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/cli": "^7.25.9",
"@babel/preset-typescript": "^7.24.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.12",
"@relative-ci/agent": "^4.2.13",
"@testing-library/react": "^12.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
@@ -270,7 +271,7 @@
"@types/glob": "^8.0.1",
"@types/google.analytics": "^0.0.46",
"@types/invariant": "^2.2.37",
"@types/jest": "^29.5.13",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^8.5.9",
"@types/katex": "^0.16.7",
"@types/koa": "^2.15.0",
@@ -285,7 +286,6 @@
"@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",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.14.2",
@@ -306,7 +306,7 @@
"@types/react-table": "^7.7.18",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.15",
"@types/readable-stream": "^4.0.18",
"@types/redis-info": "^3.0.3",
"@types/refractor": "^3.4.1",
"@types/resolve-path": "^1.4.2",
@@ -341,7 +341,7 @@
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^8.0.3",
"i18next-parser": "^7.9.0",
"jest-cli": "^29.7.0",
@@ -351,15 +351,16 @@
"nodemon": "^3.1.7",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"react-refresh": "^0.14.0",
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^0.4.1",
"terser": "^5.32.0",
"terser": "^5.36.0",
"typescript": "^5.6.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
"resolutions": {
"prosemirror-transform": "1.10.0",
"body-scroll-lock": "^4.0.0-beta.0",
"d3": "^7.0.0",
"debug": "4.3.4",
@@ -368,5 +369,5 @@
"qs": "6.9.7",
"rollup": "^4.5.1"
},
"version": "0.80.2"
"version": "0.81.0"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+23 -53
View File
@@ -1,9 +1,9 @@
import { Transaction } from "sequelize";
import { v4 as uuidv4 } from "uuid";
import { AttachmentPreset } from "@shared/types";
import { Attachment, Event, User } from "@server/models";
import { Attachment, User } from "@server/models";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import FileStorage from "@server/storage/files";
import { APIContext } from "@server/types";
import { RequestInit } from "@server/utils/fetch";
type BaseProps = {
@@ -17,10 +17,8 @@ type BaseProps = {
source?: "import";
/** The preset to use for the attachment */
preset: AttachmentPreset;
/** The IP address of the user creating the attachment, if available. */
ip?: string;
/** The database transaction to use for the creation */
transaction?: Transaction;
/** The request context */
ctx: APIContext;
/** Options to pass to fetch when downloading the attachment */
fetchOptions?: RequestInit;
};
@@ -42,8 +40,7 @@ export default async function attachmentCreator({
user,
source,
preset,
ip,
transaction,
ctx,
fetchOptions,
...rest
}: Props): Promise<Attachment | undefined> {
@@ -64,20 +61,15 @@ export default async function attachmentCreator({
if (!res) {
return;
}
attachment = await Attachment.create(
{
id,
key,
acl,
size: res.contentLength,
contentType: res.contentType,
teamId: user.teamId,
userId: user.id,
},
{
transaction,
}
);
attachment = await Attachment.createWithCtx(ctx, {
id,
key,
acl,
size: res.contentLength,
contentType: res.contentType,
teamId: user.teamId,
userId: user.id,
});
} else {
const { buffer, type } = rest;
await FileStorage.store({
@@ -88,38 +80,16 @@ export default async function attachmentCreator({
acl,
});
attachment = await Attachment.create(
{
id,
key,
acl,
size: buffer.length,
contentType: type,
teamId: user.teamId,
userId: user.id,
},
{
transaction,
}
);
}
await Event.create(
{
name: "attachments.create",
data: {
name,
source,
},
modelId: attachment.id,
attachment = await Attachment.createWithCtx(ctx, {
id,
key,
acl,
size: buffer.length,
contentType: type,
teamId: user.teamId,
actorId: user.id,
ip,
},
{
transaction,
}
);
userId: user.id,
});
}
return attachment;
}

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