Compare commits

...

74 Commits

Author SHA1 Message Date
Tom Moor 6a4b99ca43 refactor 2025-02-08 19:17:06 -05:00
Tom Moor 9bf8c5c633 fix: autoFocus new docs 2025-02-08 17:56:24 -05:00
Tom Moor fe3e712555 Add inline creation for collection 2025-02-08 16:54:54 -05:00
Tom Moor 6e85e99f78 Untitled document name missing in breadcrumb 2025-02-08 16:17:40 -05:00
Tom Moor 0e07d06a91 wip 2025-02-08 15:56:07 -05:00
Tom Moor cc38c4fedb fix: Copy and paste embed results in link 2025-02-08 10:20:15 -05:00
Tom Moor 749b9cc6b8 fix: Mis-sized frame embeds in tables, closes #8357 2025-02-08 09:52:37 -05:00
Tom Moor be4ce4ba2e fix: Remove remobe link button in read-only mode, closes #8350 2025-02-07 22:20:40 -05:00
Tom Moor 7afcce47ae fix: One source of scroll movement from remote edits 2025-02-07 22:20:40 -05:00
Hemachandar 7eb2bc9a16 Unsubscribe from document updates when a user is removed from a document (#8349)
* Unsubscribe from document updates when a user is removed from a document

* dummy default
2025-02-07 05:31:46 -08:00
Tom Moor 67adb66c8b fix: toMarkdown not implemented when copying an individual table cell 2025-02-06 21:25:48 -05:00
Tom Moor 247a50be62 perf: Remove anonymous method in Collaborators component 2025-02-06 20:27:24 -05:00
Tom Moor 225449796a fix: Fix flickering of avatar when multiple windows open for the same user 2025-02-06 20:24:21 -05:00
Hemachandar 7e1adab035 fix: Flaky subscriptionCreator test (#8345) 2025-02-06 18:22:24 -05:00
Tom Moor aca6f55ea0 Copy to clipboard as Markdown (#8342)
* Copy as Markdown

* Avoid instantiating serializer on each copy
2025-02-06 04:29:35 -08:00
Tom Moor ce51fa9957 fix: useComponentSize should run in useLayoutEffect 2025-02-05 22:39:10 -05:00
Hemachandar 676e89a58e fix: Skip permission checks on the app when moving document using DnD (#8333) 2025-02-05 19:10:44 -08:00
Translate-O-Tron c1d4a8e373 New Crowdin updates (#8321) 2025-02-04 19:59:29 -08:00
Tom Moor 7801bcb8e7 chore: Add default values for local development 2025-02-04 22:34:49 -05:00
Johnr24 d4cdf4288e Update .env.sample (#8323) 2025-02-04 19:33:58 -08:00
dependabot[bot] efcea0a7f2 chore(deps): bump the aws group with 5 updates (#8328)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.735.0` | `3.740.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.735.0` | `3.740.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.735.0` | `3.740.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.735.0` | `3.740.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.734.0` | `3.740.0` |


Updates `@aws-sdk/client-s3` from 3.735.0 to 3.740.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.740.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.735.0 to 3.740.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.740.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.735.0 to 3.740.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.740.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.735.0 to 3.740.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.740.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.734.0 to 3.740.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.740.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-04 19:04:45 -08:00
Tom Moor 5004281077 Combine useComponentSize hooks, possible fix for #8337 (#8338) 2025-02-04 18:49:13 -08:00
Tom Moor 2443be9329 fix: hash-api-keys migration accesses columns before creation, closes #8336 2025-02-04 19:41:00 -05:00
Apoorv Mishra 6f49cb62c3 fix: remove legacy code (#8335) 2025-02-04 16:06:01 -08:00
Tom Moor 6f50ea1d60 fix: Regression, cannot mention in comments 2025-02-03 20:32:30 -05:00
Tom Moor 52679db853 fix: Cannot read properties of null (reading 'data') 2025-02-03 20:08:02 -05:00
Tom Moor 9a94e2dcf2 fix: Cannot read properties of null (reading '0') 2025-02-03 20:00:48 -05:00
dependabot[bot] c990ace2e2 chore(deps): bump i18next-http-backend from 2.7.1 to 2.7.3 (#8330)
Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 2.7.1 to 2.7.3.
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/commits/v2.7.3)

---
updated-dependencies:
- dependency-name: i18next-http-backend
  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>
2025-02-03 16:59:32 -08:00
dependabot[bot] 7a7912b07e chore(deps-dev): bump terser from 5.36.0 to 5.37.0 (#8329)
Bumps [terser](https://github.com/terser/terser) from 5.36.0 to 5.37.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.36.0...v5.37.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>
2025-02-03 16:59:17 -08:00
dependabot[bot] 05a7627148 chore(deps-dev): bump @types/express-useragent from 1.0.2 to 1.0.5 (#8331)
Bumps [@types/express-useragent](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/express-useragent) from 1.0.2 to 1.0.5.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/express-useragent)

---
updated-dependencies:
- dependency-name: "@types/express-useragent"
  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>
2025-02-03 16:58:58 -08:00
dependabot[bot] 5ba613ac27 chore(deps): bump winston from 3.13.0 to 3.17.0 (#8332)
Bumps [winston](https://github.com/winstonjs/winston) from 3.13.0 to 3.17.0.
- [Release notes](https://github.com/winstonjs/winston/releases)
- [Changelog](https://github.com/winstonjs/winston/blob/master/CHANGELOG.md)
- [Commits](https://github.com/winstonjs/winston/compare/v3.13.0...v3.17.0)

---
updated-dependencies:
- dependency-name: winston
  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>
2025-02-03 16:58:48 -08:00
Tom Moor c717e8e3eb Move collection description into dedicated tab (#8326)
* Move collection description into dedicated tab

* Dynamic default

* refactor
2025-02-03 16:58:35 -08:00
Tom Moor 144d83e68c fix: Missing key prop on facepile 2025-02-01 10:59:37 -05:00
Tom Moor abd6518854 fix: Sidebar active state lost on collection tabs 2025-02-01 10:12:49 -05:00
Tom Moor 9c12498162 Change facepile to clip path (#8325)
* Change to clip path

* tsc

* Remove showBorder prop

* fix: Facepile size prop, tons of cleanup
2025-02-01 06:42:51 -08:00
Tom Moor aa879d8fab chore: fix production build 2025-01-30 21:38:04 -05:00
Tom Moor 28aebc9fbf feat: Upload remote-hosted images on paste (#8301)
* First pass

* fix

* tidy, tidy

* Determine dimensions

* docs

* test getFileNameFromUrl

* PR feedback

* tsc
2025-01-30 17:24:07 -08:00
Translate-O-Tron abaeba5952 New Crowdin updates (#8278) 2025-01-28 17:54:47 -08:00
Tom Moor b666d8f13d fix: Dropbox OIDC requires POST to userinfo endpoint (#8282) 2025-01-28 17:54:04 -08:00
Hemachandar 8e4844fd84 Convert UserMembership mutations (#8285)
* handle collections.add_user, collections.remove_user

* handle documents.add_user, documents.remove_user

* don't publish event in collection creation flow
2025-01-28 17:52:31 -08:00
Tom Moor 15892a9364 feat: API key resource scoping (#8297) 2025-01-28 16:50:22 -08:00
dependabot[bot] 23a89c4d7b chore(deps): bump the aws group with 5 updates (#8309)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.693.0` | `3.735.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.693.0` | `3.735.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.693.0` | `3.735.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.693.0` | `3.735.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.693.0` | `3.734.0` |


Updates `@aws-sdk/client-s3` from 3.693.0 to 3.735.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.735.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.693.0 to 3.735.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.735.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.693.0 to 3.735.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.735.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.693.0 to 3.735.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.735.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.693.0 to 3.734.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.734.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-28 07:02:05 +05:30
dependabot[bot] f1c5b145a4 chore(deps-dev): bump @types/node from 20.17.14 to 20.17.16 (#8311)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.17.14 to 20.17.16.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  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>
2025-01-27 17:27:51 -08:00
dependabot[bot] 4c7b36dfca chore(deps): bump react-hook-form from 7.53.1 to 7.54.2 (#8310)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.53.1 to 7.54.2.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.53.1...v7.54.2)

---
updated-dependencies:
- dependency-name: react-hook-form
  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>
2025-01-27 17:27:40 -08:00
dependabot[bot] e1d0d4717c chore(deps): bump @tanstack/react-virtual from 3.10.9 to 3.11.3 (#8312)
Bumps [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) from 3.10.9 to 3.11.3.
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Commits](https://github.com/TanStack/virtual/commits/v3.11.3/packages/react-virtual)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  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>
2025-01-27 17:27:03 -08:00
dependabot[bot] e3f836c22b chore(deps): bump the babel group with 6 updates (#8308)
Bumps the babel group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.25.2` | `7.26.7` |
| [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) | `7.24.7` | `7.25.9` |
| [@babel/plugin-transform-class-properties](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-class-properties) | `7.25.7` | `7.25.9` |
| [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring) | `7.25.7` | `7.25.9` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.25.8` | `7.26.7` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.24.1` | `7.26.0` |


Updates `@babel/core` from 7.25.2 to 7.26.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.7/packages/babel-core)

Updates `@babel/plugin-proposal-decorators` from 7.24.7 to 7.25.9
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.25.9/packages/babel-plugin-proposal-decorators)

Updates `@babel/plugin-transform-class-properties` from 7.25.7 to 7.25.9
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.25.9/packages/babel-plugin-transform-class-properties)

Updates `@babel/plugin-transform-destructuring` from 7.25.7 to 7.25.9
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.25.9/packages/babel-plugin-transform-destructuring)

Updates `@babel/preset-env` from 7.25.8 to 7.26.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.7/packages/babel-preset-env)

Updates `@babel/preset-typescript` from 7.24.1 to 7.26.0
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.0/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-class-properties"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 17:26:32 -08:00
dependabot[bot] e9602ada24 chore(deps): bump @dnd-kit/core from 6.1.0 to 6.3.1 (#8304)
Bumps [@dnd-kit/core](https://github.com/clauderic/dnd-kit/tree/HEAD/packages/core) from 6.1.0 to 6.3.1.
- [Release notes](https://github.com/clauderic/dnd-kit/releases)
- [Changelog](https://github.com/clauderic/dnd-kit/blob/master/packages/core/CHANGELOG.md)
- [Commits](https://github.com/clauderic/dnd-kit/commits/@dnd-kit/core@6.3.1/packages/core)

---
updated-dependencies:
- dependency-name: "@dnd-kit/core"
  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>
2025-01-27 17:18:50 -08:00
Tom Moor 0ff4bed18f chore: Add groups to dependabot 2025-01-27 20:13:10 -05:00
dependabot[bot] 6b49d91f2f chore(deps): bump mailparser and @types/mailparser (#8306)
Bumps [mailparser](https://github.com/nodemailer/mailparser) and [@types/mailparser](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/mailparser). These dependencies needed to be updated together.

Updates `mailparser` from 3.7.1 to 3.7.2
- [Release notes](https://github.com/nodemailer/mailparser/releases)
- [Changelog](https://github.com/nodemailer/mailparser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/mailparser/compare/v3.7.1...v3.7.2)

Updates `@types/mailparser` from 3.4.4 to 3.4.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/mailparser)

---
updated-dependencies:
- dependency-name: mailparser
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/mailparser"
  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>
2025-01-27 17:03:36 -08:00
dependabot[bot] 77f0572445 chore(deps): bump @tanstack/react-table from 8.20.5 to 8.20.6 (#8307)
Bumps [@tanstack/react-table](https://github.com/TanStack/table/tree/HEAD/packages/react-table) from 8.20.5 to 8.20.6.
- [Release notes](https://github.com/TanStack/table/releases)
- [Commits](https://github.com/TanStack/table/commits/v8.20.6/packages/react-table)

---
updated-dependencies:
- dependency-name: "@tanstack/react-table"
  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>
2025-01-27 17:03:21 -08:00
Tom Moor 5b11a0cc16 feat: Upgrade FA, add new icons 2025-01-25 15:19:58 -05:00
Tom Moor dfe97bee50 fix: Comment sidebar should not overtake scrolling if linked to anchor, closes #8296 2025-01-25 11:44:38 -05:00
Tom Moor 500730b243 chore: React warning 2025-01-25 08:09:43 -05:00
Tom Moor ec6ed809a4 Add triggers to keyboard shortcut help, make search input sticky 2025-01-25 07:49:06 -05:00
Viorel Cojocaru 08385b8a9e chore: Bump rollup-plugin-webpack-stats to v2.0.1 (#8293) 2025-01-24 15:04:07 -08:00
Tom Moor 9929020b44 chore: fix WSS connection issue in local development 2025-01-24 09:44:54 -05:00
Tom Moor 48a330347f chore: fix CORS issue in local development 2025-01-24 09:21:54 -05:00
Tom Moor 5b6bebc308 fix: Email content should account for untitled documents 2025-01-23 23:46:02 -05:00
Tom Moor c831c71c51 fix: Incorrect horizontal borders on Settings -> Profile 2025-01-23 23:41:55 -05:00
Tom Moor 90350e82fe fix: Events lacking teamId published for sourced memberships (#8295) 2025-01-23 20:19:53 -08:00
Tom Moor b7bbaac2eb fix: Default to user mention for backwards compat 2025-01-23 22:03:27 -05:00
Hemachandar 5a45b95a48 fix: Render TOC only when the shared document has headings (#8264)
* fix: Render TOC only when the shared document has headings

* simplify condition

* fix inconsistent toc button state

* toc visible check

* remove shareHasHeadings prop
2025-01-23 05:12:34 -08:00
Hemachandar 9deb9268b5 fix: Skip events for sourced group memberships (#8286) 2025-01-23 05:06:57 -08:00
Tom Moor 53f4c724bb chore: Remove duplicate trigger definition for suggestion extensions 2025-01-22 22:29:36 -05:00
Tom Moor 184e56264c feat: Add reading time on pinned documents 2025-01-22 21:17:26 -05:00
Tom Moor ffa7043cf0 fix: Outgoing emails trigger spoofing warnings due to exact matching from name 2025-01-22 20:55:10 -05:00
Tom Moor ff3c157554 fix: Crash in share menu when query looks like regex 2025-01-22 20:52:00 -05:00
Tom Moor 13f23d19fc fix: JS error selecting 'Keep as link' with keyboard.
Hacky quick fix, better coming soon
closes #8276
2025-01-22 20:47:38 -05:00
Hemachandar b527048b76 Remove namespace filter for publishing events (#8252)
* groupuser namespace

* remove namespace

* handle reactions

* handle group memberships

* cache changeset before all create and update flows
2025-01-22 17:16:05 -08:00
Apoorv Mishra e1b0cfb6a0 Use explicitly passed title and text in favor of template title and text (#8274)
* fix: use explicitly passed title and text in favor of template title and text while creating doc from the template

* fix: retain template variables when creating a template from another template
2025-01-22 05:41:18 -08:00
ZhuoYang Wu(阿离) 2205b9ee87 feat: add paste menu (#8229)
* feat: add paste menu

* fix: for comment

* Add supported embed detection

* fix: Menu is modified before it closes

* Add multiplayer support

* refactor

* perf: Avoid unneccessary mapping

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-01-22 05:41:05 -08:00
dependabot[bot] 1122f030a9 chore(deps): bump vite from 5.4.11 to 5.4.12 (#8273)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.11 to 5.4.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.12/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-22 05:15:10 -08:00
Translate-O-Tron 4cc0beb90d New Crowdin updates (#8257)
* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]
2025-01-20 16:00:33 -08:00
dependabot[bot] 16084322ca chore(deps-dev): bump @types/node from 20.14.2 to 20.17.14 (#8261)
* chore(deps-dev): bump @types/node from 20.14.2 to 20.17.14

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.14.2 to 20.17.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

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

* tsc

* tsc

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-01-20 16:00:08 -08:00
181 changed files with 4496 additions and 3330 deletions
+3
View File
@@ -1,5 +1,8 @@
URL=https://local.outline.dev:3000
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
REDIS_URL=redis://127.0.0.1:6379
SMTP_FROM_EMAIL=hello@example.com
# Enable unsafe-inline in script-src CSP directive
+2 -2
View File
@@ -12,14 +12,14 @@ UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5432/outline
DATABASE_URL=postgres://user:pass@postgres:5432/outline
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://localhost:6379
REDIS_URL=redis://redis:6379
# or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
+13
View File
@@ -13,3 +13,16 @@ updates:
update-types: ["version-update:semver-major"]
schedule:
interval: "weekly"
groups:
babel:
patterns:
- "@babel/*"
sentry:
patterns:
- "@sentry/*"
fortawesome:
patterns:
- "@fortawesome/*"
aws:
patterns:
- "@aws-sdk/*"
+16 -18
View File
@@ -7,9 +7,10 @@ export enum AvatarSize {
Small = 16,
Toast = 18,
Medium = 24,
Large = 32,
XLarge = 48,
XXLarge = 64,
Large = 28,
XLarge = 32,
XXLarge = 48,
Upload = 64,
}
export interface IAvatar {
@@ -20,36 +21,37 @@ export interface IAvatar {
}
type Props = {
/** The size of the avatar */
size: AvatarSize;
/** The source of the avatar image, if not passing a model. */
src?: string;
/** The avatar model, if not passing a source. */
model?: IAvatar;
/** The alt text for the image */
alt?: string;
showBorder?: boolean;
/** Optional click handler */
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Optional class name */
className?: string;
/** Optional style */
style?: React.CSSProperties;
};
function Avatar(props: Props) {
const { showBorder, model, style, ...rest } = props;
const { model, style, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
<Relative style={style}>
{src && !error ? (
<CircleImg
onError={handleError}
src={src}
$showBorder={showBorder}
{...rest}
/>
<CircleImg onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} $showBorder={showBorder} {...rest}>
<Initials color={model.color} {...rest}>
{model.initial}
</Initials>
) : (
<Initials $showBorder={showBorder} {...rest} />
<Initials {...rest} />
)}
</Relative>
);
@@ -65,15 +67,11 @@ const Relative = styled.div`
flex-shrink: 0;
`;
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
const CircleImg = styled.img<{ size: number }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: ${(props) =>
props.$showBorder === false
? "none"
: `2px solid ${props.theme.background}`};
flex-shrink: 0;
overflow: hidden;
`;
+10 -5
View File
@@ -5,7 +5,7 @@ import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar from "./Avatar";
import Avatar, { AvatarSize } from "./Avatar";
type Props = {
user: User;
@@ -14,6 +14,8 @@ type Props = {
isObserving: boolean;
isCurrentUser: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
size?: AvatarSize;
style?: React.CSSProperties;
};
function AvatarWithPresence({
@@ -23,6 +25,8 @@ function AvatarWithPresence({
isEditing,
isObserving,
isCurrentUser,
size = AvatarSize.Large,
style,
}: Props) {
const { t } = useTranslation();
const status = isPresent
@@ -47,13 +51,14 @@ function AvatarWithPresence({
}
placement="bottom"
>
<AvatarWrapper
<AvatarPresence
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
style={style}
>
<Avatar model={user} onClick={onClick} size={32} />
</AvatarWrapper>
<Avatar model={user} onClick={onClick} size={size} />
</AvatarPresence>
</Tooltip>
</>
);
@@ -69,7 +74,7 @@ type AvatarWrapperProps = {
$color: string;
};
const AvatarWrapper = styled.div<AvatarWrapperProps>`
const AvatarPresence = styled.div<AvatarWrapperProps>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
+8 -6
View File
@@ -3,9 +3,12 @@ import { s } from "@shared/styles";
import Flex from "~/components/Flex";
const Initials = styled(Flex)<{
/** The color of the background, defaults to textTertiary. */
color?: string;
/** Content is only used to calculate font size, use children to render. */
content?: string;
/** The size of the avatar */
size: number;
$showBorder?: boolean;
}>`
align-items: center;
justify-content: center;
@@ -13,15 +16,14 @@ const Initials = styled(Flex)<{
width: 100%;
height: 100%;
color: ${s("white75")};
background-color: ${(props) => props.color};
background-color: ${(props) => props.color ?? props.theme.textTertiary};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0;
font-size: ${(props) => props.size / 2}px;
// adjust font size down for each additional character
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
font-weight: 500;
`;
+41 -34
View File
@@ -7,7 +7,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document";
import { AvatarWithPresence } from "~/components/Avatar";
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
@@ -78,49 +78,56 @@ function Collaborators(props: Props) {
placement: "bottom-end",
});
const renderAvatar = React.useCallback(
({ model: collaborator, ...rest }) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== currentUserId;
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
},
[presentIds, ui, currentUserId, editingIds]
);
return (
<>
<PopoverDisclosure {...popover}>
{(popoverProps) => (
<NudeButton
width={Math.min(collaborators.length, limit) * 32}
height={32}
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
height={AvatarSize.Large}
{...popoverProps}
>
<Facepile
size={AvatarSize.Large}
limit={limit}
overflow={collaborators.length - limit}
overflow={Math.max(0, collaborators.length - limit)}
users={collaborators}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== user.id;
return (
<AvatarWithPresence
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
}}
renderAvatar={renderAvatar}
/>
</NudeButton>
)}
+31 -185
View File
@@ -1,6 +1,5 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -8,23 +7,14 @@ import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import { withUIExtensions } from "~/editor/extensions";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
const extensions = [
...richExtensions,
BlockMenuExtension,
EmojiMenuExtension,
HoverPreviewsExtension,
];
const extensions = withUIExtensions(richExtensions);
type Props = {
collection: Collection;
@@ -33,33 +23,8 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
}, []);
const handleStopEditing = React.useCallback(() => {
setEditing(false);
}, []);
const handleClickDisclosure = React.useCallback(
(event) => {
event.preventDefault();
if (isExpanded && document.activeElement) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
document.activeElement.blur();
}
setExpanded(!isExpanded);
},
[isExpanded]
);
const handleSave = React.useMemo(
() =>
debounce(async (getValue) => {
@@ -67,7 +32,6 @@ function CollectionDescription({ collection }: Props) {
await collection.save({
data: getValue(false),
});
setDirty(false);
} catch (err) {
toast.error(t("Sorry, an error occurred saving the collection"));
throw err;
@@ -76,162 +40,44 @@ function CollectionDescription({ collection }: Props) {
[collection, t]
);
const handleChange = React.useCallback(
async (getValue) => {
setDirty(true);
await handleSave(getValue);
},
[handleSave]
const childRef = React.useRef<HTMLDivElement>(null);
const childOffsetHeight = childRef.current?.offsetHeight || 0;
const editorStyle = React.useMemo(
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input data-editing={isEditing} data-expanded={isExpanded}>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense
fallback={
<Placeholder
onClick={() => {
//
}}
>
Loading
</Placeholder>
}
>
<Editor
key={key}
defaultValue={collection.data}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
extensions={extensions}
maxLength={1000}
embedsDisabled
canUpdate
/>
</React.Suspense>
) : (
can.update && (
<Placeholder
onClick={() => {
//
}}
>
{placeholder}
</Placeholder>
)
)}
</span>
</Input>
{!isEditing && (
<Disclosure
onClick={handleClickDisclosure}
aria-label={isExpanded ? t("Collapse") : t("Expand")}
size={30}
>
<Arrow />
</Disclosure>
<>
{collections.isSaving && <LoadingIndicator />}
{(collection.hasDescription || can.update) && (
<React.Suspense fallback={<Placeholder>Loading</Placeholder>}>
<Editor
defaultValue={collection.data}
onChange={handleSave}
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={1000}
canUpdate={can.update}
readOnly={!can.update}
editorStyle={editorStyle}
embedsDisabled
/>
<div ref={childRef} />
</React.Suspense>
)}
</MaxHeight>
</>
);
}
const Disclosure = styled(NudeButton)`
opacity: 0;
color: ${s("divider")};
position: absolute;
top: calc(25vh - 50px);
left: 50%;
z-index: 1;
transform: rotate(-90deg) translateX(-50%);
transition: opacity 100ms ease-in-out;
&:focus,
&:hover {
opacity: 1;
}
&:active {
color: ${s("sidebarText")};
}
`;
const Placeholder = styled(ButtonLink)`
const Placeholder = styled(Text)`
color: ${s("placeholder")};
cursor: text;
min-height: 27px;
`;
const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: 8px -8px -8px;
padding: 8px;
&[data-editing="true"],
&[data-expanded="true"] {
max-height: initial;
overflow: initial;
${Disclosure} {
top: initial;
bottom: 0;
transform: rotate(90deg) translateX(-50%);
}
}
&:hover ${Disclosure} {
opacity: 1;
}
`;
const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
&:after {
content: "";
position: absolute;
top: calc(25vh - 50px);
left: 0;
right: 0;
height: 50px;
pointer-events: none;
background: linear-gradient(
180deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${s("background")} 100%
);
}
&[data-editing="true"],
&[data-expanded="true"] {
&:after {
background: transparent;
}
}
&[data-editing="true"] {
background: ${s("backgroundSecondary")};
}
.block-menu-trigger,
.heading-anchor {
display: none !important;
}
`;
export default observer(CollectionDescription);
+25 -6
View File
@@ -1,10 +1,12 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { CollectionPermission, NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import { AuthorizationError } from "~/utils/errors";
type Props = {
/** The navigation node to move, must represent a document. */
@@ -30,12 +32,29 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
};
const handleSubmit = async () => {
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
dialogs.closeAllModals();
try {
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
{
documentName: item.title,
collectionName: collection.name,
}
)
);
} else {
toast.error(err.message);
}
} finally {
dialogs.closeAllModals();
}
};
return (
+1 -1
View File
@@ -23,7 +23,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactElement;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
+5 -4
View File
@@ -105,14 +105,15 @@ function DocumentBreadcrumb(
}
path.slice(0, -1).forEach((node: NavigationNode) => {
const title = node.title || t("Untitled");
output.push({
type: "route",
title: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {node.title}
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
node.title
title
),
to: {
pathname: node.url,
@@ -121,7 +122,7 @@ function DocumentBreadcrumb(
});
});
return output;
}, [path, category, sidebarContext, collectionNode]);
}, [t, path, category, sidebarContext, collectionNode]);
if (!collections.isLoaded) {
return null;
@@ -134,7 +135,7 @@ function DocumentBreadcrumb(
{path.slice(0, -1).map((node: NavigationNode) => (
<React.Fragment key={node.id}>
<SmallSlash />
{node.title}
{node.title || t("Untitled")}
</React.Fragment>
))}
</>
+30 -3
View File
@@ -1,8 +1,9 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -18,6 +19,7 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { useTextStats } from "~/hooks/useTextStats";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -70,6 +72,10 @@ function DocumentCard(props: Props) {
[pin]
);
// If the document was updated within the last 7 days, show a timestamp instead of reading time
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
return (
<Reorderable
ref={setNodeRef}
@@ -142,8 +148,14 @@ function DocumentCard(props: Props) {
: document.titleWithDefault}
</Heading>
<DocumentMeta size="xsmall">
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
{isRecentlyUpdated ? (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
) : (
<ReadingTime document={document} />
)}
</DocumentMeta>
</div>
</Content>
@@ -164,6 +176,21 @@ function DocumentCard(props: Props) {
);
}
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
color,
+8 -2
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -71,7 +71,13 @@ function DocumentViews({ document, isOpen }: Props) {
key={model.id}
title={model.name}
subtitle={subtitle}
image={<Avatar key={model.id} model={model} size={32} />}
image={
<Avatar
key={model.id}
model={model}
size={AvatarSize.Large}
/>
}
border={false}
small
/>
+8 -4
View File
@@ -17,7 +17,7 @@ import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -49,11 +49,15 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const previousCommentIds = React.useRef<string[]>();
const handleUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
async (file: File | string) => {
const options = {
documentId: id,
preset: AttachmentPreset.DocumentAttachment,
});
};
const result =
file instanceof File
? await uploadFile(file, options)
: await uploadFileFromUrl(file, options);
return result.url;
},
[id]
+2 -2
View File
@@ -16,7 +16,7 @@ import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import Document from "~/models/Document";
import Event from "~/models/Event";
import { Avatar } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
@@ -153,7 +153,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
onClick={handleTimeClick}
/>
}
image={<Avatar model={event.actor} size={32} />}
image={<Avatar model={event.actor} size={AvatarSize.Large} />}
subtitle={
<Subtitle>
{icon}
+62 -38
View File
@@ -1,17 +1,26 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Initials from "./Avatar/Initials";
type Props = {
/** The users to display */
users: User[];
/** The size of the avatars, defaults to AvatarSize.Large */
size?: number;
/** A number to show as the number of additional users */
overflow?: number;
/** The maximum number of users to display, defaults to 8 */
limit?: number;
renderAvatar?: (user: User) => React.ReactNode;
/** A component to render the avatar, defaults to Avatar. */
renderAvatar?: React.ComponentType<
React.ComponentProps<typeof Avatar> & {
model: User;
}
>;
};
function Facepile({
@@ -19,55 +28,70 @@ function Facepile({
overflow = 0,
size = AvatarSize.Large,
limit = 8,
renderAvatar = DefaultAvatar,
renderAvatar = Avatar,
...rest
}: Props) {
const filtered = users.filter(Boolean).slice(-limit);
const Component = renderAvatar;
return (
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
</More>
<Initials size={size} content={String(overflow)}>
{users.length ? "+" : ""}
{overflow}
</Initials>
)}
{users
.filter(Boolean)
.slice(0, limit)
.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
{filtered.map((model, index) => {
const lastChild = index === 0 && overflow <= 0;
return (
<Component
key={model.id}
{...{
model,
size,
style: {
marginRight: lastChild ? 0 : -4,
...(lastChild || filtered.length === 1
? {}
: { clipPath: `url(#${clipPathId(size)})` }),
},
}}
/>
);
})}
<FacepileClip size={size} />
</Avatars>
);
}
function DefaultAvatar(user: User) {
return <Avatar model={user} size={AvatarSize.Large} />;
function FacepileClip({ size }: { size: number }) {
return (
<SVG
width="25"
height="28"
viewBox="0 0 25 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<clipPath id={clipPathId(size)}>
<path
transform={size !== 28 ? `scale(${size / 28})` : ""}
d="M14.0633 0.5C18.1978 0.5 21.8994 2.34071 24.3876 5.24462C22.8709 7.81315 22.0012 10.8061 22.0012 14C22.0012 17.1939 22.8709 20.1868 24.3876 22.7554C21.8994 25.6593 18.1978 27.5 14.0633 27.5C6.57035 27.5 0.5 21.4537 0.5 14C0.5 6.54628 6.57035 0.5 14.0633 0.5Z"
/>
</clipPath>
</SVG>
);
}
const AvatarWrapper = styled.div`
margin-right: -8px;
function clipPathId(size: number) {
return `facepile-${size}`;
}
&:first-child {
margin-right: 0;
}
`;
const More = styled.div<{ size: number }>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 100%;
background: ${(props) => props.theme.textTertiary};
color: ${s("white")};
border: 2px solid ${s("background")};
text-align: center;
font-size: 12px;
font-weight: 600;
const SVG = styled.svg`
position: absolute;
top: 0;
left: 0;
`;
const Avatars = styled(Flex)`
+1 -1
View File
@@ -6,12 +6,12 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
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";
+1 -1
View File
@@ -1,7 +1,7 @@
import { m, TargetAndTransition } from "framer-motion";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import useComponentSize from "~/hooks/useComponentSize";
import { useComponentSize } from "@shared/hooks/useComponentSize";
type Props = {
/** The children to render */
@@ -201,11 +201,7 @@ export const AccessControlList = observer(
<ListItem
key={membership.id}
image={
<Avatar
model={membership.user}
size={AvatarSize.Medium}
showBorder={false}
/>
<Avatar model={membership.user} size={AvatarSize.Medium} />
}
title={membership.user.name}
subtitle={membership.user.email}
@@ -146,7 +146,7 @@ export const AccessControlList = observer(
/>
) : (
<ListItem
image={<Avatar model={user} showBorder={false} />}
image={<Avatar model={user} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
@@ -160,9 +160,7 @@ export const AccessControlList = observer(
) : document.isDraft ? (
<>
<ListItem
image={
<Avatar model={document.createdBy} showBorder={false} />
}
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
@@ -73,9 +73,7 @@ const DocumentMemberListItem = ({
return (
<ListItem
title={user.name}
image={
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
}
image={<Avatar model={user} size={AvatarSize.Medium} />}
subtitle={
membership?.sourceId ? (
<Trans>
@@ -158,13 +158,7 @@ export const Suggestions = observer(
: suggestion.isViewer
? t("Viewer")
: t("Editor"),
image: (
<Avatar
model={suggestion}
size={AvatarSize.Medium}
showBorder={false}
/>
),
image: <Avatar model={suggestion} size={AvatarSize.Medium} />,
};
}
+4 -1
View File
@@ -14,6 +14,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
import { AvatarSize } from "../Avatar";
import { useTeamContext } from "../TeamContext";
import TeamLogo from "../TeamLogo";
import Sidebar from "./Sidebar";
@@ -40,7 +41,9 @@ function SharedSidebar({ rootNode, shareId }: Props) {
{teamAvailable && (
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
image={
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
}
onClick={() =>
history.push(
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
-1
View File
@@ -228,7 +228,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
alt={user.name}
model={user}
size={24}
showBorder={false}
style={{ marginLeft: 4 }}
/>
}
@@ -2,26 +2,29 @@ import { Location } from "history";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { CollectionValidation } from "@shared/validations";
import { mergeRefs } from "react-merge-refs";
import { useHistory } from "react-router-dom";
import { UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
import { createDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import CollectionMenu from "~/menus/CollectionMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink, { DragObject } from "./SidebarLink";
import SidebarLink from "./SidebarLink";
type Props = {
collection: Collection;
@@ -41,12 +44,14 @@ const CollectionLink: React.FC<Props> = ({
depth,
onClick,
}: Props) => {
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const { documents } = useStores();
const history = useHistory();
const can = usePolicy(collection);
const { t } = useTranslation();
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
const editableTitleRef = React.useRef<RefHandle>(null);
const handleTitleChange = React.useCallback(
@@ -58,119 +63,127 @@ const CollectionLink: React.FC<Props> = ({
[collection]
);
// Drop to re-parent document
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: async (item: DragObject, monitor) => {
const { id, collectionId } = item;
if (monitor.didDrop()) {
return;
}
if (!collection) {
return;
}
const handleExpand = React.useCallback(() => {
if (!expanded) {
onDisclosureClick();
}
}, [expanded, onDisclosureClick]);
const document = documents.get(id);
if (collection.id === collectionId && !document?.parentDocumentId) {
return;
}
const prevCollection = collections.get(collectionId);
if (
prevCollection &&
prevCollection.permission !== collection.permission &&
!document?.isDraft
) {
dialogs.openModal({
title: t("Change permissions?"),
content: <ConfirmMoveDialog item={item} collection={collection} />,
});
} else {
await documents.move({ documentId: id, collectionId: collection.id });
if (!expanded) {
onDisclosureClick();
}
}
},
canDrop: () => can.createDocument,
collect: (monitor) => ({
isOver: !!monitor.isOver({
shallow: true,
}),
canDrop: monitor.canDrop(),
}),
});
const parentRef = React.useRef<HTMLDivElement>(null);
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
handleExpand,
parentRef
);
const handlePrefetch = React.useCallback(() => {
void collection.fetchDocuments();
}, [collection]);
const context = useActionContext({
activeCollectionId: collection.id,
sidebarContext,
});
const handleRename = React.useCallback(() => {
editableTitleRef.current?.setIsEditing(true);
}, [editableTitleRef]);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
const newDocument = await documents.create(
{
collectionId: collection.id,
title: input,
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument);
closeAddingNewChild();
history.replace(documentEditPath(newDocument));
},
[user, closeAddingNewChild, history, collection, documents]
);
return (
<Relative ref={drop}>
<DropToImport collectionId={collection.id}>
<>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
onClick={onClick}
to={{
pathname: collection.path,
state: { sidebarContext },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={setIsEditing}
canUpdate={can.update}
maxLength={CollectionValidation.maxNameLength}
ref={editableTitleRef}
/>
}
exact={false}
depth={depth ? depth : 0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
<CollectionMenu
collection={collection}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/>
</DropToImport>
</Relative>
{isAddingNewChild && (
<SidebarLink
onClick={onClick}
to={{
pathname: collection.path,
state: { sidebarContext },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
icon={<CollectionIcon collection={collection} expanded={expanded} />}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
depth={2}
isActive={() => true}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={setIsEditing}
canUpdate={can.update}
maxLength={CollectionValidation.maxNameLength}
ref={editableTitleRef}
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
/>
}
exact={false}
depth={depth ? depth : 0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
action={createDocument}
context={context}
hideOnActionDisabled
>
<PlusIcon />
</NudeButton>
<CollectionMenu
collection={collection}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/>
</DropToImport>
</Relative>
)}
</>
);
};
@@ -1,25 +1,23 @@
import noop from "lodash/noop";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { toast } from "sonner";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import DocumentsLoader from "~/components/DocumentsLoader";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Text from "~/components/Text";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import useCollectionDocuments from "../hooks/useCollectionDocuments";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarLink, { DragObject } from "./SidebarLink";
import SidebarLink from "./SidebarLink";
type Props = {
/** The collection to render the children of. */
@@ -36,55 +34,17 @@ function CollectionLinkChildren({
prefetchDocument,
}: Props) {
const pageSize = 250;
const can = usePolicy(collection);
const manualSort = collection.sort.field === "index";
const { documents, dialogs, collections } = useStores();
const { documents } = useStores();
const { t } = useTranslation();
const childDocuments = useCollectionDocuments(collection, documents.active);
const [showing, setShowing] = React.useState(pageSize);
const dummyRef = React.useRef<HTMLDivElement>(null);
// Drop to reorder document
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
accept: "document",
drop: (item: DragObject) => {
if (!manualSort && item.collectionId === collection?.id) {
toast.message(
t(
"You can't reorder documents in an alphabetically sorted collection"
)
);
return;
}
if (!collection) {
return;
}
const prevCollection = collections.get(item.collectionId);
if (
prevCollection &&
prevCollection.permission !== collection.permission
) {
dialogs.openModal({
title: t("Change permissions?"),
content: (
<ConfirmMoveDialog item={item} collection={collection} index={0} />
),
});
} else {
void documents.move({
documentId: item.id,
collectionId: collection.id,
index: 0,
});
}
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
isDraggingAnyDocument: !!monitor.canDrop(),
}),
});
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
noop,
dummyRef
);
React.useEffect(() => {
if (!expanded) {
@@ -100,12 +60,8 @@ function CollectionLinkChildren({
return (
<Folder expanded={expanded}>
{isDraggingAnyDocument && can.createDocument && manualSort && (
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
{canDrop && collection.isManualSort && (
<DropCursor isActiveDrop={isOver} innerRef={dropRef} position="top" />
)}
<DocumentsLoader collection={collection} enabled={expanded}>
{!childDocuments && (
@@ -10,6 +10,7 @@ import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
import { createCollection } from "~/actions/definitions/collections";
import useStores from "~/hooks/useStores";
import { DragObject } from "../hooks/useDragAndDrop";
import DraggableCollectionLink from "./DraggableCollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
@@ -17,7 +18,6 @@ import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction";
import SidebarContext from "./SidebarContext";
import { DragObject } from "./SidebarLink";
function Collections() {
const { documents, collections } = useStores();
@@ -3,10 +3,11 @@ import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { NavigationNode, UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
@@ -15,10 +16,11 @@ import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { newNestedDocumentPath } from "~/utils/routeHelpers";
import { documentEditPath } from "~/utils/routeHelpers";
import {
useDragDocument,
useDropToReorderDocument,
@@ -58,6 +60,7 @@ function InnerDocumentLink(
) {
const { documents, policies } = useStores();
const { t } = useTranslation();
const history = useHistory();
const canUpdate = usePolicy(node.id).update;
const isActiveDocument = activeDocument && activeDocument.id === node.id;
const hasChildDocuments =
@@ -67,6 +70,7 @@ function InnerDocumentLink(
const [isEditing, setIsEditing] = React.useState(false);
const editableTitleRef = React.useRef<RefHandle>(null);
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
React.useEffect(() => {
if (
@@ -216,6 +220,31 @@ function InnerDocumentLink(
[setExpanded, setCollapsed, hasChildren, expanded]
);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
const newDocument = await documents.create(
{
collectionId: collection?.id,
parentDocumentId: node.id,
fullWidth:
doc?.fullWidth ??
user.getPreference(UserPreference.FullWidthDocuments),
title: input,
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument, node.id);
closeAddingNewChild();
history.replace(documentEditPath(newDocument));
},
[documents, collection, user, node, doc, history, closeAddingNewChild]
);
return (
<>
<Relative ref={parentRef}>
@@ -282,8 +311,11 @@ function InnerDocumentLink(
<NudeButton
type={undefined}
aria-label={t("New nested document")}
as={Link}
to={newNestedDocumentPath(document.id)}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
>
<PlusIcon />
</NudeButton>
@@ -308,8 +340,25 @@ function InnerDocumentLink(
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
{isAddingNewChild && (
<SidebarLink
isActive={() => true}
depth={depth + 1}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
/>
}
/>
)}
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, index) => (
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
@@ -318,7 +367,7 @@ function InnerDocumentLink(
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={index}
index={childIndex}
parentId={node.id}
/>
))}
@@ -9,12 +9,12 @@ import Document from "~/models/Document";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { DragObject } from "../hooks/useDragAndDrop";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DropCursor from "./DropCursor";
import Relative from "./Relative";
import { useSidebarContext } from "./SidebarContext";
import { DragObject } from "./SidebarLink";
type Props = {
collection: Collection;
@@ -3,23 +3,33 @@ import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
onSubmit: (title: string) => Promise<void>;
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
/** A callback when the title is submitted. */
onSubmit: (title: string) => Promise<void> | void;
/** A callback when the editing status changes. */
onEditing?: (isEditing: boolean) => void;
/** A callback when editing is canceled. */
onCancel?: () => void;
/** The default title. */
title: string;
/** Whether the user can update the title. */
canUpdate: boolean;
/** The maximum length of the title. */
maxLength?: number;
/** The default editing state. */
isEditing?: boolean;
};
export type RefHandle = {
/** A function to set the editing state. */
setIsEditing: (isEditing: boolean) => void;
};
function EditableTitle(
{ title, onSubmit, canUpdate, onEditing, ...rest }: Props,
{ title, onSubmit, canUpdate, onEditing, onCancel, ...rest }: Props,
ref: React.RefObject<RefHandle>
) {
const [isEditing, setIsEditing] = React.useState(false);
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
@@ -59,6 +69,7 @@ function EditableTitle(
if (trimmedValue === originalValue || trimmedValue.length === 0) {
setValue(originalValue);
onCancel?.();
return;
}
@@ -73,7 +84,7 @@ function EditableTitle(
}
}
},
[originalValue, value, onSubmit]
[originalValue, value, onCancel, onSubmit]
);
const handleKeyDown = React.useCallback(
@@ -83,13 +94,14 @@ function EditableTitle(
}
if (ev.key === "Escape") {
setIsEditing(false);
onCancel?.();
setValue(originalValue);
}
if (ev.key === "Enter") {
await handleSave(ev);
}
},
[handleSave, originalValue]
[handleSave, onCancel, originalValue]
);
React.useEffect(() => {
@@ -4,7 +4,6 @@ import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
import useUnmount from "~/hooks/useUnmount";
@@ -12,11 +11,6 @@ import { undraggableOnDesktop } from "~/styles";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
export type DragObject = NavigationNode & {
depth: number;
collectionId: string;
};
type Props = Omit<NavLinkProps, "to"> & {
to?: LocationDescriptor;
innerRef?: (ref: HTMLElement | null | undefined) => void;
@@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next";
import DocumentDelete from "~/scenes/DocumentDelete";
import useStores from "~/hooks/useStores";
import { trashPath } from "~/utils/routeHelpers";
import SidebarLink, { DragObject } from "./SidebarLink";
import { DragObject } from "../hooks/useDragAndDrop";
import SidebarLink from "./SidebarLink";
function TrashLink() {
const { policies, dialogs, documents } = useStores();
+170 -60
View File
@@ -15,10 +15,49 @@ import Star from "~/models/Star";
import UserMembership from "~/models/UserMembership";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { DragObject } from "../components/SidebarLink";
import { AuthorizationError } from "~/utils/errors";
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
export type DragObject = NavigationNode & {
depth: number;
collectionId: string;
};
function useHover(
elementRef: React.RefObject<HTMLDivElement>,
callback: () => void
) {
const hoverTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
const startHover = React.useCallback(() => {
if (!hoverTimeoutRef.current) {
hoverTimeoutRef.current = setTimeout(() => {
hoverTimeoutRef.current = undefined;
callback();
}, 500);
}
}, [callback]);
const unsetHover = React.useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = undefined;
}
}, []);
// We set a timeout when the user first starts hovering over the document link,
// to trigger expansion of children. Clear this timeout when they stop hovering.
React.useEffect(() => {
const element = elementRef.current;
element?.addEventListener("dragleave", unsetHover);
return () => element?.removeEventListener("dragleave", unsetHover);
}, [elementRef, unsetHover]);
return startHover;
}
/**
* Hook for shared logic that allows dragging a Starred item
*
@@ -162,6 +201,84 @@ export function useDragDocument(
return [{ isDragging }, draggableRef] as const;
}
export function useDropToChangeCollection(
collection: Collection,
expandNode: () => void,
parentRef: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const { documents, collections, dialogs } = useStores();
const can = usePolicy(collection);
const startHover = useHover(parentRef, expandNode);
return useDrop<
DragObject,
Promise<void>,
{ isOver: boolean; canDrop: boolean }
>({
accept: "document",
drop: async (item, monitor) => {
if (monitor.didDrop()) {
return;
}
const { id, collectionId } = item;
const prevCollection = collections.get(collectionId);
const document = documents.get(id);
if (
prevCollection &&
prevCollection.permission !== collection.permission &&
!document?.isDraft
) {
dialogs.openModal({
title: t("Change permissions?"),
content: (
<ConfirmMoveDialog item={item} collection={collection} index={0} />
),
});
} else {
try {
await documents.move({
documentId: id,
collectionId: collection.id,
index: 0,
});
expandNode();
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
{
documentName: item.title,
collectionName: collection.name,
}
)
);
} else {
toast.error(err.message);
}
}
}
},
canDrop: () => can.createDocument,
hover: (_, monitor) => {
if (
collection.hasDocuments &&
monitor.canDrop() &&
monitor.isOver({ shallow: true })
) {
startHover();
}
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
});
}
/**
* Hook for shared logic that allows dropping documents to reparent
*
@@ -175,7 +292,7 @@ export function useDropToReparentDocument(
parentRef: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const { documents, collections, dialogs, policies } = useStores();
const { documents, collections, dialogs } = useStores();
const hasChildDocuments = !!node?.children.length;
const document = node ? documents.get(node.id) : undefined;
const pathToNode = React.useMemo(
@@ -183,25 +300,7 @@ export function useDropToReparentDocument(
[document]
);
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
// We set a timeout when the user first starts hovering over the document link,
// to trigger expansion of children. Clear this timeout when they stop hovering.
React.useEffect(() => {
const resetHoverExpanding = () => {
if (hoverExpanding.current) {
clearTimeout(hoverExpanding.current);
hoverExpanding.current = undefined;
}
};
const element = parentRef.current;
element?.addEventListener("dragleave", resetHoverExpanding);
return () => {
element?.removeEventListener("dragleave", resetHoverExpanding);
};
}, [parentRef]);
const startHover = useHover(parentRef, setExpanded);
return useDrop<
DragObject,
@@ -214,7 +313,9 @@ export function useDropToReparentDocument(
return;
}
const collection = documents.get(node.id)?.collection;
const collection = node.collectionId
? collections.get(node.collectionId)
: undefined;
const prevCollection = collections.get(item.collectionId);
if (
@@ -233,22 +334,40 @@ export function useDropToReparentDocument(
),
});
} else {
await documents.move({
documentId: item.id,
parentDocumentId: node.id,
});
try {
await documents.move({
documentId: item.id,
parentDocumentId: node.id,
});
setExpanded();
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"{{ documentName }} cannot be moved within {{ parentDocumentName }}",
{
documentName: item.title,
parentDocumentName: node.title,
}
)
);
} else {
toast.error(err.message);
}
}
}
},
canDrop: (item) => {
if (!node || item.id === node.id) {
return false;
}
setExpanded();
if (!document) {
return true; // optimistic, in case the document is not loaded yet; server will check for permissions before performing the move.
}
return document.isActive && !!pathToNode && !pathToNode.includes(item.id);
},
canDrop: (item, monitor) =>
!!node &&
!!pathToNode &&
!pathToNode.includes(monitor.getItem().id) &&
item.id !== node.id &&
!!document?.isActive &&
policies.abilities(node.id).update &&
policies.abilities(item.id).move,
hover: (_item, monitor) => {
// Enables expansion of document children when hovering over the document
// for more than half a second.
@@ -259,15 +378,7 @@ export function useDropToReparentDocument(
shallow: true,
})
) {
if (!hoverExpanding.current) {
hoverExpanding.current = setTimeout(() => {
hoverExpanding.current = undefined;
if (monitor.isOver({ shallow: true })) {
setExpanded();
}
}, 500);
}
startHover();
}
},
collect: (monitor) => ({
@@ -297,7 +408,7 @@ export function useDropToReorderDocument(
}
) {
const { t } = useTranslation();
const { documents, collections, dialogs, policies } = useStores();
const { documents, collections, dialogs } = useStores();
const document = documents.get(node.id);
@@ -308,22 +419,9 @@ export function useDropToReorderDocument(
>({
accept: "document",
canDrop: (item: DragObject) => {
if (
item.id === node.id ||
!policies.abilities(item.id)?.move ||
!document?.isActive
) {
if (item.id === node.id || (document && !document.isActive)) {
return false;
}
const params = getMoveParams(item);
if (params?.collectionId) {
return policies.abilities(params.collectionId)?.updateDocument;
}
if (params?.parentDocumentId) {
return policies.abilities(params.parentDocumentId)?.update;
}
return true;
},
drop: async (item) => {
@@ -357,7 +455,19 @@ export function useDropToReorderDocument(
),
});
} else {
void documents.move(params);
try {
await documents.move(params);
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t("The {{ documentName }} cannot be moved here", {
documentName: item.title,
})
);
} else {
toast.error(err.message);
}
}
}
}
},
+1 -1
View File
@@ -190,7 +190,7 @@ class WebsocketProvider extends React.Component<Props> {
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
if (!collection?.documents?.length && !event.fetchIfMissing) {
if (!collection?.documents && !event.fetchIfMissing) {
continue;
}
+1 -4
View File
@@ -6,10 +6,7 @@ import SuggestionsMenu, {
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
type Props = Omit<
SuggestionsMenuProps,
"renderMenuItem" | "items" | "trigger"
> &
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
Required<Pick<SuggestionsMenuProps, "embeds">>;
function BlockMenu(props: Props) {
+1 -2
View File
@@ -17,7 +17,7 @@ type Emoji = {
type Props = Omit<
SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "embeds" | "trigger"
"renderMenuItem" | "items" | "embeds"
>;
const EmojiMenu = (props: Props) => {
@@ -48,7 +48,6 @@ const EmojiMenu = (props: Props) => {
return (
<SuggestionsMenu
{...props}
trigger=":"
filterable={false}
renderMenuItem={(item, _index, options) => (
<EmojiMenuItem
+3 -3
View File
@@ -6,10 +6,10 @@ import styled, { css } from "styled-components";
import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
@@ -184,10 +184,10 @@ function usePosition({
// of the selection still
const offset = left - (centerOfSelection - menuWidth / 2);
return {
left: Math.round(left - offsetParent.left),
left: Math.max(margin, Math.round(left - offsetParent.left)),
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
blockSelection: codeBlock || isColSelection || isRowSelection,
visible: true,
};
+9 -6
View File
@@ -187,7 +187,7 @@ class LinkEditor extends React.Component<Props, State> {
};
render() {
const { dictionary } = this.props;
const { view, dictionary } = this.props;
const { value } = this.state;
const isInternal = isInternalUrl(value);
@@ -202,6 +202,7 @@ class LinkEditor extends React.Component<Props, State> {
onChange={this.handleSearch}
onFocus={this.handleSearch}
autoFocus={this.href === ""}
readOnly={!view.editable}
/>
<Tooltip
@@ -211,11 +212,13 @@ class LinkEditor extends React.Component<Props, State> {
{isInternal ? <ArrowIcon /> : <OpenIcon />}
</ToolbarButton>
</Tooltip>
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
)}
</Wrapper>
);
}
+4 -3
View File
@@ -35,7 +35,7 @@ interface MentionItem extends MenuItem {
type Props = Omit<
SuggestionsMenuProps<MentionItem>,
"renderMenuItem" | "items" | "embeds" | "trigger"
"renderMenuItem" | "items" | "embeds"
>;
function MentionMenu({ search, isActive, ...rest }: Props) {
@@ -84,7 +84,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
>
<Avatar
model={user}
showBorder={false}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
@@ -158,6 +157,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
if (item.attrs.type === MentionType.Document) {
return;
}
if (!documentId) {
return;
}
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
@@ -194,7 +196,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
{...rest}
isActive={isActive}
filterable={false}
trigger="@"
search={search}
onSelect={handleSelect}
renderMenuItem={(item, _index, options) => (
+68
View File
@@ -0,0 +1,68 @@
import { LinkIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { EmbedDescriptor } from "@shared/editor/embeds";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
type Props = Omit<
SuggestionsMenuProps,
"renderMenuItem" | "items" | "embeds" | "trigger"
> & {
pastedText: string;
embeds: EmbedDescriptor[];
};
const PasteMenu = ({ embeds, ...props }: Props) => {
const { t } = useTranslation();
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(props.pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, props.pastedText]);
const items = React.useMemo(
() => [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
);
return (
<SuggestionsMenu
{...props}
trigger=""
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={() => {
props.onSelect?.(item);
}}
selected={options.selected}
title={item.title}
icon={item.icon}
/>
)}
items={items}
/>
);
};
export default PasteMenu;
+5 -2
View File
@@ -233,7 +233,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const attrs =
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
if (command) {
if (item.name === "noop") {
// Do nothing
} else if (command) {
command(attrs);
} else {
commands[`create${capitalize(item.name)}`](attrs);
@@ -435,7 +437,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
if (
item.name &&
!commands[item.name] &&
!commands[`create${capitalize(item.name)}`]
!commands[`create${capitalize(item.name)}`] &&
item.name !== "noop"
) {
return false;
}
@@ -12,7 +12,7 @@ export type Props = {
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
icon?: React.ReactElement;
icon?: React.ReactNode;
/** The title of the item */
title: React.ReactNode;
/** An optional subtitle for the item */
+16 -10
View File
@@ -99,22 +99,28 @@ export default class BlockMenuExtension extends Suggestion {
];
}
private handleClose = action((insertNewLine: boolean) => {
const { view } = this.editor;
if (insertNewLine) {
const transaction = view.state.tr.split(view.state.selection.to);
view.dispatch(transaction);
view.focus();
}
this.state.open = false;
});
widget = ({ rtl }: WidgetProps) => {
const { props, view } = this.editor;
const { props } = this.editor;
return (
<BlockMenu
rtl={rtl}
trigger={this.options.trigger}
isActive={this.state.open}
search={this.state.query}
onClose={action((insertNewLine) => {
if (insertNewLine) {
const transaction = view.state.tr.split(view.state.selection.to);
view.dispatch(transaction);
view.focus();
}
this.state.open = false;
})}
onClose={this.handleClose}
uploadFile={props.uploadFile}
onFileUploadStart={props.onFileUploadStart}
onFileUploadStop={props.onFileUploadStop}
@@ -1,12 +1,9 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import textBetween from "@shared/editor/lib/textBetween";
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
/**
* A plugin that allows overriding the default behavior of the editor to allow
* copying text for nodes that do not inherently have text children by defining
* a `toPlainText` method in the node spec.
* copying text including the markdown formatting.
*/
export default class ClipboardTextSerializer extends Extension {
get name() {
@@ -14,20 +11,16 @@ export default class ClipboardTextSerializer extends Extension {
}
get plugins() {
const textSerializers = getTextSerializers(this.editor.schema);
const serializer = this.editor.extensions.serializer();
return [
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: () => {
const { doc, selection } = this.editor.view.state;
const { ranges } = selection;
const from = Math.min(...ranges.map((range) => range.$from.pos));
const to = Math.max(...ranges.map((range) => range.$to.pos));
return textBetween(doc, from, to, textSerializers);
},
clipboardTextSerializer: (slice) =>
serializer.serialize(slice.content, {
softBreak: true,
}),
},
}),
];
+1
View File
@@ -33,6 +33,7 @@ export default class EmojiMenuExtension extends Suggestion {
widget = ({ rtl }: WidgetProps) => (
<EmojiMenu
rtl={rtl}
trigger={this.options.trigger}
isActive={this.state.open}
search={this.state.query}
onClose={action(() => {
+1
View File
@@ -21,6 +21,7 @@ export default class MentionMenuExtension extends Suggestion {
widget = ({ rtl }: WidgetProps) => (
<MentionMenu
rtl={rtl}
trigger={this.options.trigger}
isActive={this.state.open}
search={this.state.query}
onClose={action(() => {
+7
View File
@@ -1,4 +1,5 @@
import isEqual from "lodash/isEqual";
import { Plugin } from "prosemirror-state";
import {
ySyncPlugin,
yCursorPlugin,
@@ -8,6 +9,7 @@ import {
} from "y-prosemirror";
import * as Y from "yjs";
import Extension from "@shared/editor/lib/Extension";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
import { Second } from "@shared/utils/time";
type UserAwareness = {
@@ -103,6 +105,11 @@ export default class Multiplayer extends Extension {
selectionBuilder,
}),
yUndoPlugin(),
new Plugin({
props: {
handleScrollToSelection: (view) => isRemoteTransaction(view.state.tr),
},
}),
];
}
@@ -1,18 +1,29 @@
import { action, observable } from "mobx";
import { toggleMark } from "prosemirror-commands";
import { Slice } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import {
EditorState,
Plugin,
PluginKey,
TextSelection,
} from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import { v4 } from "uuid";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension from "@shared/editor/lib/Extension";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
@@ -61,13 +72,26 @@ function parseSingleIframeSrc(html: string) {
}
export default class PasteHandler extends Extension {
state: {
open: boolean;
query: string;
pastedText: string;
} = observable({
open: false,
query: "",
pastedText: "",
});
get name() {
return "paste-handler";
}
private key = new PluginKey(this.name);
get plugins() {
return [
new Plugin({
key: this.key,
props: {
transformPastedHTML(html: string) {
if (isDropboxPaper(html)) {
@@ -107,23 +131,6 @@ export default class PasteHandler extends Extension {
const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data");
function insertLink(href: string, title?: string) {
// If it's not an embed and there is no text selected just go ahead and insert the
// link directly
const transaction = view.state.tr
.insertText(
title ?? href,
state.selection.from,
state.selection.to
)
.addMark(
state.selection.from,
state.selection.to + (title ?? href).length,
state.schema.marks.link.create({ href })
);
view.dispatch(transaction);
}
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(state)) {
@@ -141,6 +148,13 @@ export default class PasteHandler extends Extension {
const supportsCodeMark = !!state.schema.marks.code_inline;
if (!this.shiftKey) {
// If the HTML on the clipboard is from Prosemirror then the best
// compatability is to just use the HTML parser, regardless of
// whether it "looks" like Markdown, see: outline/outline#2416
if (html?.includes("data-pm-slice")) {
return false;
}
// Check if the clipboard contents can be parsed as a single url
if (isUrl(text)) {
// If there is selected text then we want to wrap it in a link to the url
@@ -152,28 +166,6 @@ export default class PasteHandler extends Extension {
return true;
}
// Is this link embeddable? Create an embed!
const { embeds } = this.editor.props;
if (
embeds &&
this.editor.commands.embed &&
!isInCode(state) &&
!isInList(state)
) {
for (const embed of embeds) {
if (!embed.matchOnInput) {
continue;
}
const matches = embed.matcher(text);
if (matches) {
this.editor.commands.embed({
href: text,
});
return true;
}
}
}
// Is the link a link to a document? If so, we can grab the title and insert it.
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
@@ -209,7 +201,7 @@ export default class PasteHandler extends Extension {
hasEmoji ? document.icon + " " : ""
}${document.titleWithDefault}`;
insertLink(`${document.path}${hash}`, title);
this.insertLink(`${document.path}${hash}`, title);
}
}
})
@@ -217,11 +209,11 @@ export default class PasteHandler extends Extension {
if (view.isDestroyed) {
return;
}
insertLink(text);
this.insertLink(text);
});
}
} else {
insertLink(text);
this.insertLink(text);
}
return true;
@@ -264,13 +256,6 @@ export default class PasteHandler extends Extension {
return true;
}
}
// If the HTML on the clipboard is from Prosemirror then the best
// compatability is to just use the HTML parser, regardless of
// whether it "looks" like Markdown, see: outline/outline#2416
if (html?.includes("data-pm-slice")) {
return false;
}
}
// If the text on the clipboard looks like Markdown OR there is no
@@ -323,10 +308,170 @@ export default class PasteHandler extends Extension {
return false;
},
},
state: {
init: () => DecorationSet.empty,
apply: (tr, set) => {
let mapping = tr.mapping;
// See if the transaction adds or removes any placeholders
const meta = tr.getMeta(this.key);
const hasDecorations = set.find().length;
// We only want a single paste placeholder at a time, so if we're adding a new
// placeholder we can just return a new DecorationSet and avoid mapping logic.
if (meta?.add) {
const { from, to, id } = meta.add;
const decorations = [
Decoration.inline(
from,
to,
{
class: "paste-placeholder",
},
{
id,
}
),
];
return DecorationSet.create(tr.doc, decorations);
}
if (hasDecorations && (isRemoteTransaction(tr) || meta)) {
try {
mapping = recreateTransform(tr.before, tr.doc, {
complexSteps: true,
wordDiffs: false,
simplifyDiff: true,
}).mapping;
} catch (err) {
// eslint-disable-next-line no-console
console.warn("Failed to recreate transform: ", err);
}
}
set = set.map(mapping, tr.doc);
if (meta?.remove) {
const { id } = meta.remove;
const decorations = set.find(
undefined,
undefined,
(spec) => spec.id === id
);
return set.remove(decorations);
}
return set;
},
},
}),
];
}
/** Tracks whether the Shift key is currently held down */
private shiftKey = false;
private showPasteMenu = action((text: string) => {
this.state.pastedText = text;
this.state.open = true;
});
private hidePasteMenu = action(() => {
this.state.open = false;
});
private insertLink(href: string, title?: string) {
const { view } = this.editor;
const { state } = view;
const { from } = state.selection;
const to = from + (title ?? href).length;
const transaction = view.state.tr
.insertText(title ?? href, state.selection.from, state.selection.to)
.addMark(from, to, state.schema.marks.link.create({ href }))
.setMeta(this.key, { add: { from, to, id: href } });
view.dispatch(transaction);
this.showPasteMenu(href);
}
private insertEmbed = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
this.editor.commands.embed({
href: this.state.pastedText,
});
};
private removePlaceholder = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
if (result) {
view.dispatch(
state.tr.setMeta(this.key, {
remove: { id: this.state.pastedText },
})
);
}
};
private findPlaceholder = (
state: EditorState,
id: string
): [number, number] | null => {
const decos = this.key.getState(state) as DecorationSet;
const found = decos?.find(undefined, undefined, (spec) => spec.id === id);
return found?.length ? [found[0].from, found[0].to] : null;
};
private handleSelect = (item: MenuItem) => {
switch (item.name) {
case "noop": {
this.hidePasteMenu();
this.removePlaceholder();
break;
}
case "embed": {
this.hidePasteMenu();
this.insertEmbed();
break;
}
default:
break;
}
};
keys() {
return {
Backspace: () => {
this.hidePasteMenu();
return false;
},
"Mod-z": () => {
this.hidePasteMenu();
return false;
},
};
}
widget = ({ rtl }: WidgetProps) => (
<PasteMenu
rtl={rtl}
embeds={this.editor.props.embeds}
pastedText={this.state.pastedText}
isActive={this.state.open}
search={this.state.query}
onClose={this.hidePasteMenu}
onSelect={this.handleSelect}
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
import Extension from "@shared/editor/lib/Extension";
import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SmartText from "~/editor/extensions/SmartText";
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
export const withUIExtensions = (nodes: Nodes) => [
...nodes,
SmartText,
PasteHandler,
ClipboardTextSerializer,
BlockMenuExtension,
EmojiMenuExtension,
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
// Order these default key handlers last
PreventTab,
Keys,
];
-35
View File
@@ -1,35 +0,0 @@
import { useState, useLayoutEffect } from "react";
export default function useComponentSize(
ref: React.RefObject<HTMLElement | null>
): {
width: number;
height: number;
} {
const [size, setSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver((entries) => {
entries.forEach(({ target }) => {
if (
size.width !== target.clientWidth ||
size.height !== target.clientHeight
) {
setSize({ width: target.clientWidth, height: target.clientHeight });
}
});
});
if (ref.current) {
setSize({
width: ref.current?.clientWidth,
height: ref.current?.clientHeight,
});
sizeObserver.observe(ref.current);
}
return () => sizeObserver.disconnect();
}, [ref, size.height, size.width]);
return size;
}
+33
View File
@@ -0,0 +1,33 @@
import emojiRegex from "emoji-regex";
/**
* Hook to calculate text statistics
* @param text The string to calculate statistics for
* @param selectedText A substring of the text to calculate statistics for
* @returns An object containing total and selected statistics
*/
export function useTextStats(text: string, selectedText: string = "") {
const numTotalWords = countWords(text);
const regex = emojiRegex();
const matches = Array.from(text.matchAll(regex));
return {
total: {
words: numTotalWords,
characters: text.length,
emoji: matches.length ?? 0,
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
},
selected: {
words: countWords(selectedText),
characters: selectedText.length,
},
};
}
function countWords(text: string): number {
const t = text.trim();
// Hyphenated words are counted as two words
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
}
+6 -1
View File
@@ -6,11 +6,16 @@ import Field from "./decorators/Field";
class ApiKey extends ParanoidModel {
static modelName = "ApiKey";
/** The user chosen name of the API key. */
/** The human-readable name of this API key */
@Field
@observable
name: string;
/** A list of scopes that this API key has access to. If empty, the key has full access. */
@Field
@observable
scope?: string[];
/** An optional datetime that the API key expires. */
@Field
@observable
+35
View File
@@ -181,6 +181,11 @@ export default class Collection extends ParanoidModel {
return !this.isArchived && !this.isDeleted;
}
@computed
get hasDocuments() {
return !!this.documents?.length;
}
fetchDocuments = async (options?: { force: boolean }) => {
if (this.isFetching) {
return;
@@ -258,6 +263,36 @@ export default class Collection extends ParanoidModel {
});
}
/**
* Adds the document identified by the given id to the collection in
* memory. Does not add the document to the database or store.
*
* @param document The document to add.
* @param parentDocumentId The id of the document to add the new document to.
*/
@action
addDocument(document: Document, parentDocumentId?: string) {
if (!this.documents) {
return;
}
if (!parentDocumentId) {
this.documents.unshift(document.asNavigationNode);
return;
}
const travelNodes = (nodes: NavigationNode[]) =>
nodes.forEach((node) => {
if (node.id === parentDocumentId) {
node.children = [document.asNavigationNode, ...(node.children ?? [])];
} else {
travelNodes(node.children);
}
});
travelNodes(this.documents);
}
@action
updateIndex(index: string) {
this.index = index;
+1 -2
View File
@@ -72,8 +72,7 @@ function AuthenticatedRoutes() {
<Redirect exact from="/templates" to={settingsPath("templates")} />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route exact path="/collection/:id/:tab" component={Collection} />
<Route exact path="/collection/:id" component={Collection} />
<Route exact path="/collection/:id/:tab?" component={Collection} />
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
+22 -6
View File
@@ -22,6 +22,7 @@ type Props = {
function ApiKeyNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [scope, setScope] = React.useState("");
const [expiryType, setExpiryType] = React.useState<ExpiryType>(
ExpiryType.Week
);
@@ -51,6 +52,10 @@ function ApiKeyNew({ onSubmit }: Props) {
setName(event.target.value);
}, []);
const handleScopeChange = React.useCallback((event) => {
setScope(event.target.value);
}, []);
const handleExpiryTypeChange = React.useCallback((value: string) => {
const expiry = value as ExpiryType;
setExpiryType(expiry);
@@ -70,6 +75,7 @@ function ApiKeyNew({ onSubmit }: Props) {
await apiKeys.create({
name,
expiresAt: expiresAt?.toISOString(),
scope: scope ? scope.split(" ") : undefined,
});
toast.success(
t(
@@ -83,20 +89,16 @@ function ApiKeyNew({ onSubmit }: Props) {
setIsSaving(false);
}
},
[t, name, expiresAt, onSubmit, apiKeys]
[t, name, scope, expiresAt, onSubmit, apiKeys]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{t(
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
)}
</Text>
<Flex column>
<Input
type="text"
label={t("Name")}
placeholder={t("Development")}
onChange={handleNameChange}
value={name}
minLength={ApiKeyValidation.minNameLength}
@@ -105,6 +107,20 @@ function ApiKeyNew({ onSubmit }: Props) {
autoFocus
flex
/>
<Input
type="text"
label={t("Scopes")}
placeholder="documents.info"
onChange={handleScopeChange}
value={scope}
flex
/>
<Text type="secondary" size="small" as="p">
{t(
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access"
)}
.
</Text>
<Flex align="center" gap={16}>
<StyledExpirySelect
ariaLabel={t("Expiration")}
@@ -4,7 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import Collection from "~/models/Collection";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
@@ -44,10 +44,10 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
groupMemberships.fetchPage(options),
]);
if (users[PAGINATION_SYMBOL]) {
setUsersCount(users[PAGINATION_SYMBOL].total);
setUsersCount(users[PAGINATION_SYMBOL].total ?? 0);
}
if (groups[PAGINATION_SYMBOL]) {
setGroupsCount(groups[PAGINATION_SYMBOL].total);
setGroupsCount(groups[PAGINATION_SYMBOL].total ?? 0);
}
} finally {
setIsLoading(false);
@@ -101,12 +101,10 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
>
<Fade>
<Facepile
size={AvatarSize.Large}
users={sortBy(collectionUsers, "lastActiveAt")}
overflow={overflow}
limit={limit}
renderAvatar={(item) => (
<Avatar model={item} size={AvatarSize.Large} />
)}
/>
</Fade>
</NudeButton>
+188 -102
View File
@@ -3,12 +3,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import {
useParams,
Redirect,
Switch,
Route,
useHistory,
useRouteMatch,
useLocation,
Redirect,
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -30,12 +30,13 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePersistedState from "~/hooks/usePersistedState";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -49,7 +50,16 @@ import ShareButton from "./components/ShareButton";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
function CollectionScene() {
enum CollectionPath {
Overview = "overview",
Recent = "recent",
Updated = "updated",
Published = "published",
Old = "old",
Alphabetical = "alphabetical",
}
const CollectionScene = observer(function _CollectionScene() {
const params = useParams<{ id?: string }>();
const history = useHistory();
const match = useRouteMatch();
@@ -60,17 +70,26 @@ function CollectionScene() {
const [error, setError] = React.useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
const sidebarContext = useLocationSidebarContext();
const id = params.id || "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(id, collection?.id);
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
`collection-tab:${collection?.id}`,
collection?.hasDescription
? CollectionPath.Overview
: CollectionPath.Recent,
{
listen: false,
}
);
const handleIconChange = React.useCallback(
async (icon: string | null, color: string | null) => {
await collection?.save({ icon, color });
},
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
[collection]
);
@@ -120,6 +139,8 @@ function CollectionScene() {
return <Search notFound />;
}
const hasOverview = can.update || collection?.hasDescription;
const fallbackIcon = collection ? (
<Icon
value={collection.icon ?? "collection"}
@@ -128,11 +149,17 @@ function CollectionScene() {
/>
) : null;
const tabProps = (path: CollectionPath) => ({
exact: true,
onClick: () => setCollectionTab(path),
to: {
pathname: collectionPath(collection!.path, path),
state: { sidebarContext },
},
});
return collection ? (
<Scene
// Forced mount prevents animation of pinned documents when navigating
// _between_ collections, speeds up perceived performance.
key={collection.id}
centered={false}
textTitle={collection.name}
left={
@@ -196,105 +223,156 @@ function CollectionScene() {
canUpdate={can.update}
placeholderCount={count}
/>
<CollectionDescription collection={collection} />
<Documents>
{!collection.isArchived && (
<Tabs>
<Tab to={collectionPath(collection.path)} exact>
{t("Documents")}
<Tabs>
{hasOverview && (
<Tab {...tabProps(CollectionPath.Overview)}>
{t("Overview")}
</Tab>
<Tab to={collectionPath(collection.path, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionPath(collection.path, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
)}
{collection.isEmpty ? (
<Empty collection={collection} />
) : !collection.isArchived ? (
<Switch>
<Route path={collectionPath(collection.path, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
<Tab {...tabProps(CollectionPath.Recent)}>{t("Documents")}</Tab>
{!collection.isArchived && (
<>
<Tab {...tabProps(CollectionPath.Updated)}>
{t("Recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Published)}>
{t("Recently published")}
</Tab>
<Tab {...tabProps(CollectionPath.Old)}>
{t("Least recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Alphabetical)}>
{t("AZ")}
</Tab>
</>
)}
</Tabs>
<Switch>
<Route path={collectionPath(collection.path)} exact>
<Redirect
to={{
pathname: collectionPath(collection!.path, collectionTab),
state: { sidebarContext },
}}
/>
</Route>
<Route
path={collectionPath(collection.path, CollectionPath.Overview)}
>
{hasOverview ? (
<CollectionDescription collection={collection} />
) : (
<Redirect
to={{
pathname: collectionPath(
collection.path,
CollectionPath.Recent
),
state: { sidebarContext },
}}
/>
)}
</Route>
{collection.isEmpty ? (
<Empty collection={collection} />
) : !collection.isArchived ? (
<>
<Route
path={collectionPath(
collection.path,
CollectionPath.Alphabetical
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route
path={collectionPath(collection.path, CollectionPath.Old)}
>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Published
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path, "recent")}>
<Redirect to={collectionPath(collection.path, "published")} />
</Route>
<Route path={collectionPath(collection.path, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Updated
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
/>
</Route>
<Route path={collectionPath(collection.path, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Recent
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
}}
showParentDocuments
/>
</Route>
</Switch>
) : (
<Switch>
<Route path={collectionPath(collection.path)} exact>
exact
>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
}}
showParentDocuments
/>
</Route>
</>
) : (
<Route
path={collectionPath(collection.path, CollectionPath.Recent)}
exact
>
<PaginatedDocumentList
documents={documents.archivedInCollection(collection.id)}
fetch={documents.fetchPage}
heading={<Subheading sticky>{t("Documents")}</Subheading>}
options={{
collectionId: collection.id,
parentDocumentId: null,
@@ -305,8 +383,8 @@ function CollectionScene() {
showParentDocuments
/>
</Route>
</Switch>
)}
)}
</Switch>
</Documents>
</CenteredContent>
</DropToImport>
@@ -319,7 +397,15 @@ function CollectionScene() {
<PlaceholderList count={5} />
</CenteredContent>
);
}
});
const KeyedCollection = () => {
const params = useParams<{ id?: string }>();
// Forced mount prevents animation of pinned documents when navigating
// _between_ collections, speeds up perceived performance.
return <CollectionScene key={params.id} />;
};
const Documents = styled.div`
position: relative;
@@ -337,4 +423,4 @@ const CollectionHeading = styled(Heading)`
`}
`;
export default observer(CollectionScene);
export default KeyedCollection;
+23 -25
View File
@@ -217,33 +217,31 @@ function SharedDocumentScene(props: Props) {
);
}
const SharedDocument = ({
shareId,
response,
}: {
shareId?: string;
response: Response;
}) => {
const { setDocument } = useDocumentContext();
const SharedDocument = observer(
({ shareId, response }: { shareId?: string; response: Response }) => {
const { hasHeadings, setDocument } = useDocumentContext();
if (!response.document) {
return null;
if (!response.document) {
return null;
}
const tocPosition = hasHeadings
? response.team?.tocPosition ?? TOCPosition.Left
: false;
setDocument(response.document);
return (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
);
}
const tocPosition = response.team?.tocPosition ?? TOCPosition.Left;
setDocument(response.document);
return (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
);
};
);
const Content = styled(Text)`
color: ${s("textSecondary")};
@@ -11,7 +11,7 @@ import { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
@@ -54,7 +54,7 @@ function CommentThread({
collapseThreshold = 5,
collapseNumDisplayed = 3,
}: Props) {
const [focusedOnMount] = React.useState(focused);
const [scrollOnMount] = React.useState(focused && !window.location.hash);
const { editor } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
@@ -149,9 +149,6 @@ function CommentThread({
limit={limit}
overflow={overflow}
size={AvatarSize.Medium}
renderAvatar={(item) => (
<Avatar size={AvatarSize.Medium} model={item} />
)}
/>
</ShowMore>
);
@@ -165,7 +162,7 @@ function CommentThread({
React.useEffect(() => {
if (focused) {
if (focusedOnMount) {
if (scrollOnMount) {
setTimeout(() => {
if (!topRef.current) {
return;
@@ -209,7 +206,7 @@ function CommentThread({
isMarkVisible ? 0 : sidebarAppearDuration
);
}
}, [focused, focusedOnMount, thread.id]);
}, [focused, scrollOnMount, thread.id]);
return (
<Thread
@@ -243,7 +243,7 @@ function CommentThreadItem({
onOpen={disableScroll}
onClose={enableScroll}
size={28}
rounded
$rounded
/>
) : undefined
}
@@ -264,7 +264,7 @@ function CommentThreadItem({
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
rounded
$rounded
/>
</>
)}
@@ -302,7 +302,7 @@ const ResolveButton = ({
comment,
onResolve: () => onUpdate({ resolved: true }),
})}
rounded
$rounded
>
<DoneIcon size={22} outline />
</Action>
@@ -340,10 +340,10 @@ const Body = styled.form`
border-radius: 2px;
`;
const Action = styled.span<{ rounded?: boolean }>`
const Action = styled.span<{ $rounded?: boolean }>`
color: ${s("textSecondary")};
${(props) =>
props.rounded &&
props.$rounded &&
css`
border-radius: 50%;
`}
+17 -15
View File
@@ -89,7 +89,7 @@ type Props = WithTranslation &
revision?: Revision;
readOnly: boolean;
shareId?: string;
tocPosition?: TOCPosition;
tocPosition?: TOCPosition | false;
onCreateLink?: (
params: Properties<Document>,
nested?: boolean
@@ -380,24 +380,23 @@ class DocumentScene extends React.Component<Props> {
AUTOSAVE_DELAY
);
updateIsDirty = () => {
updateIsDirty = action(() => {
const { document } = this.props;
const doc = this.editor.current?.view.state.doc;
this.isEditorDirty = !isEqual(doc?.toJSON(), document.data);
// a single hash is a doc with just an empty title
this.isEditorDirty = !isEqual(doc?.toJSON(), document.data);
this.isEmpty = (!doc || ProsemirrorHelper.isEmpty(doc)) && !this.title;
};
});
updateIsDirtyDebounced = debounce(this.updateIsDirty, 500);
onFileUploadStart = () => {
onFileUploadStart = action(() => {
this.isUploading = true;
};
});
onFileUploadStop = () => {
onFileUploadStop = action(() => {
this.isUploading = false;
};
});
handleChangeTitle = action((value: string) => {
this.title = value;
@@ -438,13 +437,15 @@ class DocumentScene extends React.Component<Props> {
const embedsDisabled =
(team && team.documentEmbeds === false) || document.embedsDisabled;
const showContents =
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
const tocPos =
tocPosition ??
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
TOCPosition.Left);
const showContents =
tocPos &&
(isShare
? ui.tocVisible !== false
: !document.isTemplate && ui.tocVisible === true);
const multiplayerEditor =
!document.isArchived && !document.isDeleted && !revision && !isShare;
@@ -582,6 +583,7 @@ class DocumentScene extends React.Component<Props> {
readOnly={readOnly}
canUpdate={abilities.update}
canComment={abilities.comment}
autoFocus={document.createdAt === document.updatedAt}
>
{shareId ? (
<ReferencesWrapper>
@@ -622,7 +624,7 @@ class DocumentScene extends React.Component<Props> {
type MainProps = {
fullWidth: boolean;
tocPosition: TOCPosition;
tocPosition: TOCPosition | false;
};
const Main = styled.div<MainProps>`
@@ -650,7 +652,7 @@ const Main = styled.div<MainProps>`
type ContentsContainerProps = {
docFullWidth: boolean;
position: TOCPosition;
position: TOCPosition | false;
};
const ContentsContainer = styled.div<ContentsContainerProps>`
@@ -668,7 +670,7 @@ const ContentsContainer = styled.div<ContentsContainerProps>`
type EditorContainerProps = {
docFullWidth: boolean;
showContents: boolean;
tocPosition: TOCPosition;
tocPosition: TOCPosition | false;
};
const EditorContainer = styled.div<EditorContainerProps>`
+2 -24
View File
@@ -13,16 +13,7 @@ import { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import Editor, { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SmartText from "~/editor/extensions/SmartText";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
@@ -40,20 +31,7 @@ import MultiplayerEditor from "./AsyncMultiplayerEditor";
import DocumentMeta from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle";
const extensions = [
...withComments(richExtensions),
SmartText,
PasteHandler,
ClipboardTextSerializer,
BlockMenuExtension,
EmojiMenuExtension,
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
// Order these default key handlers last
PreventTab,
Keys,
];
const extensions = withUIExtensions(withComments(richExtensions));
type Props = Omit<EditorProps, "editorStyle"> & {
onChangeTitle: (title: string) => void;
+12 -5
View File
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { NavigationNode } from "@shared/types";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import { Theme } from "~/stores/UiStore";
@@ -31,7 +32,6 @@ 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";
@@ -96,6 +96,7 @@ function DocumentHeader({
const ref = React.useRef<HTMLDivElement | null>(null);
const size = useComponentSize(ref);
const isMobile = isMobileMedia || size.width < 700;
const isShare = !!shareId;
// 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
@@ -109,8 +110,13 @@ function DocumentHeader({
}, [onSave]);
const handleToggle = React.useCallback(() => {
ui.set({ tocVisible: !ui.tocVisible });
}, [ui]);
// Public shares, by default, show ToC on load.
if (isShare && ui.tocVisible === undefined) {
ui.set({ tocVisible: false });
} else {
ui.set({ tocVisible: !ui.tocVisible });
}
}, [ui, isShare]);
const context = useActionContext({
activeDocumentId: document?.id,
@@ -120,7 +126,6 @@ function DocumentHeader({
const { isDeleted, isTemplate } = document;
const isTemplateEditable = can.update && isTemplate;
const canToggleEmbeds = team?.documentEmbeds;
const isShare = !!shareId;
const showContents =
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
@@ -212,7 +217,9 @@ function DocumentHeader({
hasSidebar={sharedTree && sharedTree.children?.length > 0}
left={
isMobile ? (
<TableOfContentsMenu />
hasHeadings ? (
<TableOfContentsMenu />
) : null
) : (
<PublicBreadcrumb
documentId={document.id}
+3 -29
View File
@@ -1,4 +1,3 @@
import emojiRegex from "emoji-regex";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -7,7 +6,7 @@ import styled from "styled-components";
import { s } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
import User from "~/models/User";
import { Avatar } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import DocumentViews from "~/components/DocumentViews";
import Flex from "~/components/Flex";
@@ -20,6 +19,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useTextSelection from "~/hooks/useTextSelection";
import { useTextStats } from "~/hooks/useTextStats";
import InsightsMenu from "~/menus/InsightsMenu";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
@@ -136,7 +136,7 @@ function Insights() {
avatarUrl: null,
initial: document.sourceMetadata.createdByName[0],
}}
size={32}
size={AvatarSize.Large}
/>
}
subtitle={t("Creator")}
@@ -213,32 +213,6 @@ function Insights() {
);
}
function useTextStats(text: string, selectedText: string) {
const numTotalWords = countWords(text);
const regex = emojiRegex();
const matches = Array.from(text.matchAll(regex));
return {
total: {
words: numTotalWords,
characters: text.length,
emoji: matches.length ?? 0,
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
},
selected: {
words: countWords(selectedText),
characters: selectedText.length,
},
};
}
function countWords(text: string): number {
const t = text.trim();
// Hyphenated words are counted as two words
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
}
const ListSpacing = styled("div")`
margin-top: -0.5em;
margin-bottom: 0.5em;
@@ -1,5 +1,5 @@
import * as React from "react";
import useComponentSize from "@shared/hooks/useComponentSize";
import { useComponentSize } from "@shared/hooks/useComponentSize";
export const MeasuredContainer = <T extends React.ElementType>({
as: As,
+1 -1
View File
@@ -50,7 +50,7 @@ function DocumentNew({ template }: Props) {
user.getPreference(UserPreference.FullWidthDocuments),
templateId: query.get("templateId") ?? undefined,
template,
title: "",
title: query.get("title") ?? "",
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: collection?.id || parentDocumentId ? true : undefined }
+38 -6
View File
@@ -457,13 +457,32 @@ function KeyboardShortcuts() {
},
],
},
{
title: t("Triggers"),
items: [
{
shortcut: "@",
label: t("Mention user or document"),
},
{
shortcut: ":",
label: t("Emoji"),
},
{
shortcut: "/",
label: t("Insert block"),
},
],
},
],
[t]
);
const [searchTerm, setSearchTerm] = React.useState("");
const normalizedSearchTerm = searchTerm.toLocaleLowerCase();
const handleChange = React.useCallback((event) => {
setSearchTerm(event.target.value);
}, []);
const handleKeyDown = React.useCallback((event) => {
if (event.currentTarget.value && event.key === "Escape") {
event.preventDefault();
@@ -471,17 +490,20 @@ function KeyboardShortcuts() {
setSearchTerm("");
}
}, []);
return (
<Flex column>
<InputSearch
onChange={handleChange}
onKeyDown={handleKeyDown}
value={searchTerm}
/>
<StickySearch>
<InputSearch
onChange={handleChange}
onKeyDown={handleKeyDown}
value={searchTerm}
/>
</StickySearch>
{categories.map((category, x) => {
const filtered = searchTerm
? category.items.filter((item) =>
item.label.toLowerCase().includes(searchTerm.toLowerCase())
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
)
: category.items;
@@ -509,6 +531,16 @@ function KeyboardShortcuts() {
);
}
const StickySearch = styled.div`
position: sticky;
top: -16px;
z-index: 1;
padding: 16px;
margin: -16px;
background: ${s("background")};
border-radius: 8px;
`;
const Header = styled.h2`
font-size: 15px;
font-weight: 500;
+3 -2
View File
@@ -10,6 +10,7 @@ import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { Config } from "~/stores/AuthStore";
import { AvatarSize } from "~/components/Avatar";
import ButtonLarge from "~/components/ButtonLarge";
import ChangeLanguage from "~/components/ChangeLanguage";
import Fade from "~/components/Fade";
@@ -249,9 +250,9 @@ function Login({ children }: Props) {
/>
<Logo>
{config.logo && !isCreate ? (
<TeamLogo size={48} src={config.logo} />
<TeamLogo size={AvatarSize.XXLarge} src={config.logo} />
) : (
<OutlineIcon size={48} />
<OutlineIcon size={AvatarSize.XXLarge} />
)}
</Logo>
{isCreate ? (
+1 -1
View File
@@ -25,7 +25,7 @@ function UserFilter(props: Props) {
const userOptions = users.all.map((user) => ({
key: user.id,
label: user.name,
icon: <Avatar model={user} showBorder={false} size={AvatarSize.Small} />,
icon: <Avatar model={user} size={AvatarSize.Small} />,
}));
return [
{
+2 -2
View File
@@ -78,7 +78,7 @@ const Profile = () => {
/>
</SettingRow>
<SettingRow
border={false}
border={env.EMAIL_ENABLED}
label={t("Name")}
name="name"
description={t(
@@ -95,7 +95,7 @@ const Profile = () => {
</SettingRow>
{env.EMAIL_ENABLED && (
<SettingRow label={t("Email address")} name="email">
<SettingRow border={false} label={t("Email address")} name="email">
<Input
type="email"
value={user.email}
@@ -10,6 +10,7 @@ import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useUserLocale from "~/hooks/useUserLocale";
import ApiKeyMenu from "~/menus/ApiKeyMenu";
@@ -35,7 +36,7 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
&middot;{" "}
</Text>
{apiKey.lastActiveAt && (
<Text type={"tertiary"}>
<Text type="tertiary">
{t("Last used")} <Time dateTime={apiKey.lastActiveAt} addSuffix />{" "}
&middot;{" "}
</Text>
@@ -44,7 +45,20 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
{apiKey.expiresAt
? dateToExpiry(apiKey.expiresAt, t, userLocale)
: t("No expiry")}
{apiKey.scope && <> &middot; </>}
</Text>
{apiKey.scope && (
<Tooltip
content={apiKey.scope.map((s) => (
<>
{s}
<br />
</>
))}
>
<Text type="tertiary">{t("Restricted scope")}</Text>
</Tooltip>
)}
</>
);
@@ -434,7 +434,7 @@ const GroupMemberListItem = observer(function ({
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
image={<Avatar model={user} size={32} />}
image={<Avatar model={user} size={AvatarSize.Large} />}
actions={
<Flex align="center">
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
@@ -18,7 +18,7 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
<Flex gap={8} justify="space-between">
<ImageBox>
<ImageUpload onSuccess={onSuccess} {...rest}>
<StyledAvatar model={model} size={AvatarSize.XXLarge} />
<StyledAvatar model={model} size={AvatarSize.Upload} />
<Flex auto align="center" justify="center" className="upload">
{t("Upload")}
</Flex>
@@ -34,8 +34,8 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
}
const avatarStyles = `
width: ${AvatarSize.XXLarge}px;
height: ${AvatarSize.XXLarge}px;
width: ${AvatarSize.Upload}px;
height: ${AvatarSize.Upload}px;
`;
const StyledAvatar = styled(Avatar)`
@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "~/models/User";
import { Avatar } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
import Flex from "~/components/Flex";
import { HEADER_HEIGHT } from "~/components/Header";
@@ -38,7 +38,7 @@ export function PeopleTable({ canManage, ...rest }: Props) {
accessor: (user) => user.name,
component: (user) => (
<Flex align="center" gap={8}>
<Avatar model={user} size={32} /> {user.name}{" "}
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
{currentUser.id === user.id && `(${t("You")})`}
</Flex>
),
+8 -1
View File
@@ -1,5 +1,6 @@
import { observable, action } from "mobx";
import { AwarenessChangeEvent } from "~/types";
import RootStore from "./RootStore";
type DocumentPresence = Map<
string,
@@ -17,6 +18,12 @@ export default class PresenceStore {
offlineTimeout = 30000;
private rootStore: RootStore;
constructor(rootStore: RootStore) {
this.rootStore = rootStore;
}
// called when a user leaves the document
@action
public leave(documentId: string, userId: string) {
@@ -38,7 +45,7 @@ export default class PresenceStore {
event.states.forEach((state) => {
const { user, cursor } = state;
if (user) {
if (user && this.rootStore.auth.currentUserId !== user.id) {
this.update(documentId, user.id, !!cursor);
existingUserIds = existingUserIds.filter((id) => id !== user.id);
}
+1 -1
View File
@@ -106,6 +106,6 @@ export default class GroupsStore extends Store<Group> {
function queriedGroups(groups: Group[], query: string) {
return groups.filter((group) =>
group.name.toLowerCase().match(query.toLowerCase())
group.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())
);
}
+6 -6
View File
@@ -26,7 +26,7 @@ export type MenuItemButton = {
visible?: boolean;
selected?: boolean;
disabled?: boolean;
icon?: React.ReactElement;
icon?: React.ReactNode;
};
export type MenuItemWithChildren = {
@@ -38,7 +38,7 @@ export type MenuItemWithChildren = {
hover?: boolean;
items: MenuItem[];
icon?: React.ReactElement;
icon?: React.ReactNode;
};
export type MenuSeparator = {
@@ -59,7 +59,7 @@ export type MenuInternalLink = {
visible?: boolean;
selected?: boolean;
disabled?: boolean;
icon?: React.ReactElement;
icon?: React.ReactNode;
};
export type MenuExternalLink = {
@@ -70,7 +70,7 @@ export type MenuExternalLink = {
selected?: boolean;
disabled?: boolean;
level?: number;
icon?: React.ReactElement;
icon?: React.ReactNode;
};
export type MenuItem =
@@ -108,7 +108,7 @@ export type Action = {
/** Higher number is higher in results, default is 0. */
priority?: number;
iconInContextMenu?: boolean;
icon?: React.ReactElement | React.FC;
icon?: React.ReactNode;
placeholder?: ((context: ActionContext) => string) | string;
selected?: (context: ActionContext) => boolean;
visible?: (context: ActionContext) => boolean;
@@ -127,7 +127,7 @@ export type CommandBarAction = {
shortcut: string[];
keywords: string;
placeholder?: string;
icon?: React.ReactElement;
icon?: React.ReactNode;
perform?: () => void;
children?: string[];
parent?: string;
+31
View File
@@ -14,6 +14,31 @@ type UploadOptions = {
onProgress?: (fractionComplete: number) => void;
};
/**
* Upload a file from a URL
*
* @param url The remote URL to download the file from
* @param options The upload options
* @returns The attachment object
*/
export const uploadFileFromUrl = async (
url: string,
options: UploadOptions
) => {
const response = await client.post("/attachments.createFromUrl", {
documentId: options.documentId,
url,
});
return response.data;
};
/**
* Upload a file
*
* @param file The file to upload
* @param options The upload options
* @returns The attachment object
*/
export const uploadFile = async (
file: File | Blob,
options: UploadOptions = {
@@ -74,6 +99,12 @@ export const uploadFile = async (
return attachment;
};
/**
* Convert a data URL to a Blob
*
* @param dataURL The data URL to convert
* @returns The Blob
*/
export const dataUrlToBlob = (dataURL: string) => {
const blobBin = atob(dataURL.split(",")[1]);
const array = [];
+27 -27
View File
@@ -48,30 +48,30 @@
"> 0.25%, not dead"
],
"dependencies": {
"@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",
"@babel/plugin-transform-destructuring": "^7.24.8",
"@aws-sdk/client-s3": "3.740.0",
"@aws-sdk/lib-storage": "3.740.0",
"@aws-sdk/s3-presigned-post": "3.740.0",
"@aws-sdk/s3-request-presigner": "3.740.0",
"@aws-sdk/signature-v4-crt": "^3.740.0",
"@babel/core": "^7.26.7",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
"@babel/plugin-transform-destructuring": "^7.25.9",
"@babel/plugin-transform-regenerator": "^7.25.9",
"@babel/preset-env": "^7.25.8",
"@babel/preset-env": "^7.26.7",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "^4.12.2",
"@css-inline/css-inline-wasm": "^0.14.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.2",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@hocuspocus/extension-throttle": "1.1.2",
@@ -85,11 +85,11 @@
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tanstack/react-table": "^8.20.5",
"@tanstack/react-virtual": "^3.10.9",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.3",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.0",
"@types/mailparser": "^3.4.4",
"@types/mailparser": "^3.4.5",
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react": "^3.1.0",
"addressparser": "^1.0.1",
@@ -127,7 +127,7 @@
"http-errors": "2.0.0",
"i18next": "^22.5.1",
"i18next-fs-backend": "^2.6.0",
"i18next-http-backend": "^2.7.1",
"i18next-http-backend": "^2.7.3",
"invariant": "^2.2.4",
"ioredis": "^5.4.1",
"is-printable-key-event": "^1.0.0",
@@ -147,7 +147,7 @@
"koa-sslify": "5.0.1",
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
"mailparser": "^3.7.1",
"mailparser": "^3.7.2",
"mammoth": "^1.8.0",
"markdown-it": "^13.0.2",
"markdown-it-container": "^3.0.0",
@@ -199,7 +199,7 @@
"react-dom": "^17.0.2",
"react-dropzone": "^11.7.1",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.53.1",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.10",
"react-merge-refs": "^2.1.1",
@@ -241,9 +241,9 @@
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vite": "^5.4.11",
"vite": "^5.4.12",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.13.0",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
"y-prosemirror": "^1.2.12",
@@ -254,7 +254,7 @@
},
"devDependencies": {
"@babel/cli": "^7.26.4",
"@babel/preset-typescript": "^7.24.1",
"@babel/preset-typescript": "^7.26.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.13",
"@testing-library/react": "^12.0.0",
@@ -265,7 +265,7 @@
"@types/dotenv": "^8.2.3",
"@types/emoji-regex": "^9.2.0",
"@types/escape-html": "^1.0.4",
"@types/express-useragent": "^1.0.2",
"@types/express-useragent": "^1.0.5",
"@types/formidable": "^2.0.6",
"@types/fs-extra": "^11.0.4",
"@types/fuzzy-search": "^2.1.5",
@@ -289,7 +289,7 @@
"@types/markdown-it-emoji": "^2.0.4",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.14.2",
"@types/node": "20.17.16",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
@@ -353,8 +353,8 @@
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^0.4.1",
"terser": "^5.36.0",
"rollup-plugin-webpack-stats": "^2.0.1",
"terser": "^5.37.0",
"typescript": "^5.7.2",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
+6 -2
View File
@@ -57,10 +57,14 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
const [profileResponse, organizationResponse] = await Promise.all([
// Load the users profile from the Microsoft Graph API
// https://docs.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0
request(`https://graph.microsoft.com/v1.0/me`, accessToken),
request("GET", `https://graph.microsoft.com/v1.0/me`, accessToken),
// Load the organization profile from the Microsoft Graph API
// https://docs.microsoft.com/en-us/graph/api/organization-get?view=graph-rest-1.0
request(`https://graph.microsoft.com/v1.0/organization`, accessToken),
request(
"GET",
`https://graph.microsoft.com/v1.0/organization`,
accessToken
),
]);
if (!profileResponse) {
+3
View File
@@ -70,6 +70,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
const client = getClientFromContext(ctx);
/** Fetch the user's profile */
const profile: RESTGetAPICurrentUserResult = await request(
"GET",
"https://discord.com/api/users/@me",
accessToken
);
@@ -105,6 +106,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
if (env.DISCORD_SERVER_ID) {
/** Fetch the guilds a user is in */
const guilds: RESTGetAPICurrentUserGuildsResult = await request(
"GET",
"https://discord.com/api/users/@me/guilds",
accessToken
);
@@ -146,6 +148,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
/** Fetch the user's member object in the server for nickname and roles */
const guildMember: RESTGetCurrentUserGuildMemberResult =
await request(
"GET",
`https://discord.com/api/users/@me/guilds/${env.DISCORD_SERVER_ID}/member`,
accessToken
);
-1
View File
@@ -116,7 +116,6 @@ function GitHub() {
<TeamLogo
src={githubAccount?.avatarUrl}
size={AvatarSize.Large}
showBorder={false}
/>
}
actions={
+7 -1
View File
@@ -81,8 +81,14 @@ if (
) => void
) {
try {
// Some providers require a POST request to the userinfo endpoint, add them as exceptions here.
const usePostMethod = [
"https://api.dropboxapi.com/2/openid/userinfo",
];
const profile = await request(
env.OIDC_USERINFO_URI ?? "",
usePostMethod.includes(env.OIDC_USERINFO_URI!) ? "POST" : "GET",
env.OIDC_USERINFO_URI!,
accessToken
);
@@ -102,6 +102,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "api_keys.create":
case "api_keys.delete":
case "attachments.create":
case "attachments.update":
case "attachments.delete":
case "subscriptions.create":
case "subscriptions.delete":
+16 -10
View File
@@ -35,8 +35,8 @@ type Props = Optional<
};
export default async function documentCreator({
title = "",
text = "",
title,
text,
icon,
color,
state,
@@ -101,14 +101,20 @@ export default async function documentCreator({
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
icon: templateDocument ? templateDocument.icon : icon,
color: templateDocument ? templateDocument.color : color,
title: TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.title : title,
user
),
text: TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.text : text,
user
),
title:
title ??
(templateDocument
? template
? templateDocument.title
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
: ""),
text:
text ??
(templateDocument
? template
? templateDocument.text
: TextHelper.replaceTemplateVariables(templateDocument.text, user)
: ""),
content: templateDocument
? ProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(templateDocument),
@@ -210,6 +210,7 @@ describe("subscriptionCreator", () => {
where: {
teamId: user.teamId,
},
order: [["createdAt", "ASC"]],
});
expect(events.length).toEqual(3);
+4 -1
View File
@@ -191,9 +191,12 @@ export default abstract class BaseEmail<
const parsedFrom = addressparser(env.SMTP_FROM_EMAIL)[0];
const domain = parsedFrom.address.split("@")[1];
const customFromName = this.fromName?.(props);
return {
name: this.fromName?.(props) ?? parsedFrom.name,
name: customFromName
? `${customFromName} via ${env.APP_NAME}`
: parsedFrom.name,
address:
env.isCloudHosted &&
this.category === EmailMessageCategory.Authentication
@@ -103,7 +103,7 @@ export default class CommentCreatedEmail extends BaseEmail<
: `${commentText.slice(0, MAX_SUBJECT_CONTENT)}...`;
return `${parentComment ? "Re: " : ""}New comment on “${
document.title
document.titleWithDefault
}” - ${trimmedText}`;
}
@@ -136,7 +136,7 @@ export default class CommentCreatedEmail extends BaseEmail<
}: Props): string {
return `
${actorName} ${isReply ? "replied to a thread in" : "commented on"} "${
document.title
document.titleWithDefault
}"${collection?.name ? `in the ${collection.name} collection` : ""}.
Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
@@ -164,10 +164,10 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
<Header />
<Body>
<Heading>{document.title}</Heading>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} {isReply ? "replied to a thread in" : "commented on"}{" "}
<a href={threadLink}>{document.title}</a>{" "}
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
{collection?.name ? `in the ${collection.name} collection` : ""}.
</p>
{body && (
@@ -92,7 +92,7 @@ export default class CommentMentionedEmail extends BaseEmail<
}
protected subject({ document }: Props) {
return `Mentioned you in “${document.title}`;
return `Mentioned you in “${document.titleWithDefault}`;
}
protected preview({ actorName }: Props): string {
@@ -111,7 +111,7 @@ export default class CommentMentionedEmail extends BaseEmail<
collection,
}: Props): string {
return `
${actorName} mentioned you in a comment on "${document.title}"${
${actorName} mentioned you in a comment on "${document.titleWithDefault}"${
collection.name ? `in the ${collection.name} collection` : ""
}.
@@ -139,10 +139,10 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
<Header />
<Body>
<Heading>{document.title}</Heading>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} mentioned you in a comment on{" "}
<a href={threadLink}>{document.title}</a>{" "}
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
{collection.name ? `in the ${collection.name} collection` : ""}.
</p>
{body && (
@@ -92,7 +92,7 @@ export default class CommentResolvedEmail extends BaseEmail<
}
protected subject({ document }: Props) {
return `Resolved a comment thread in “${document.title}`;
return `Resolved a comment thread in “${document.titleWithDefault}`;
}
protected preview({ actorName }: Props): string {
@@ -110,7 +110,7 @@ export default class CommentResolvedEmail extends BaseEmail<
commentId,
collection,
}: Props): string {
const t1 = `${actorName} resolved a comment thread on "${document.title}"`;
const t1 = `${actorName} resolved a comment thread on "${document.titleWithDefault}"`;
const t2 = collection.name ? ` in the ${collection.name} collection` : "";
const t3 = `Open Thread: ${teamUrl}${document.url}?commentId=${commentId}`;
return `${t1}${t2}.\n\n${t3}`;
@@ -136,10 +136,10 @@ export default class CommentResolvedEmail extends BaseEmail<
<Header />
<Body>
<Heading>{document.title}</Heading>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} resolved a comment on{" "}
<a href={threadLink}>{document.title}</a>{" "}
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
{collection.name ? `in the ${collection.name} collection` : ""}.
</p>
{body && (
@@ -92,7 +92,7 @@ export default class DocumentMentionedEmail extends BaseEmail<
}
protected subject({ document }: Props) {
return `Mentioned you in “${document.title}`;
return `Mentioned you in “${document.titleWithDefault}`;
}
protected preview({ actorName }: Props): string {
@@ -116,7 +116,7 @@ export default class DocumentMentionedEmail extends BaseEmail<
return `
You were mentioned
${actorName} mentioned you in the document “${document.title}”.
${actorName} mentioned you in the document “${document.titleWithDefault}”.
Open Document: ${teamUrl}${document.url}
`;
@@ -137,7 +137,7 @@ Open Document: ${teamUrl}${document.url}
<Heading>You were mentioned</Heading>
<p>
{actorName} mentioned you in the document{" "}
<a href={documentLink}>{document.title}</a>.
<a href={documentLink}>{document.titleWithDefault}</a>.
</p>
{body && (
<>
@@ -114,7 +114,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
}
protected subject({ document, eventType }: Props) {
return `${document.title}${this.eventName(eventType)}`;
return `${document.titleWithDefault}${this.eventName(eventType)}`;
}
protected preview({ actorName, eventType }: Props): string {
@@ -144,9 +144,9 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
const eventName = this.eventName(eventType);
return `
"${document.title}" ${eventName}
"${document.titleWithDefault}" ${eventName}
${actorName} ${eventName} the document "${document.title}"${
${actorName} ${eventName} the document "${document.titleWithDefault}"${
collection?.name ? `, in the ${collection.name} collection` : ""
}.
@@ -176,11 +176,11 @@ Open Document: ${teamUrl}${document.url}
<Body>
<Heading>
{document.title} {eventName}
{document.titleWithDefault} {eventName}
</Heading>
<p>
{actorName} {eventName} the document{" "}
<a href={documentLink}>{document.title}</a>
<a href={documentLink}>{document.titleWithDefault}</a>
{collection?.name ? <>, in the {collection.name} collection</> : ""}
.
</p>
@@ -53,7 +53,7 @@ export default class DocumentSharedEmail extends BaseEmail<
}
protected subject({ actorName, document }: Props) {
return `${actorName} shared “${document.title}” with you`;
return `${actorName} shared “${document.titleWithDefault}” with you`;
}
protected preview({ actorName }: Props): string {
@@ -66,7 +66,7 @@ export default class DocumentSharedEmail extends BaseEmail<
protected renderAsText({ actorName, teamUrl, document }: Props): string {
return `
${actorName} shared “${document.title}” with you.
${actorName} shared “${document.titleWithDefault}” with you.
View Document: ${teamUrl}${document.path}
`;
@@ -87,10 +87,10 @@ View Document: ${teamUrl}${document.path}
<Header />
<Body>
<Heading>{document.title}</Heading>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} invited you to {permission} the{" "}
<a href={documentUrl}>{document.title}</a> document.
<a href={documentUrl}>{document.titleWithDefault}</a> document.
</p>
<p>
<Button href={documentUrl}>View Document</Button>
+7 -1
View File
@@ -80,7 +80,13 @@ export default function auth(options: AuthenticationOptions = {}) {
}
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
throw AuthenticationError("Invalid API key");
throw AuthenticationError("API key is expired");
}
if (!apiKey.canAccess(ctx.request.url)) {
throw AuthenticationError(
"API key does not have access to this resource"
);
}
user = await User.findByPk(apiKey.userId, {
@@ -0,0 +1,19 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn("apiKeys", "scope", {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: true,
}, { transaction });
});
},
async down(queryInterface) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("apiKeys", "scope", { transaction });
});
},
};
+63 -5
View File
@@ -4,26 +4,26 @@ import ApiKey from "./ApiKey";
describe("#ApiKey", () => {
describe("match", () => {
test("should match an API secret", async () => {
it("should match an API secret", async () => {
const apiKey = await buildApiKey();
expect(ApiKey.match(apiKey.value!)).toBe(true);
expect(ApiKey.match(`${randomstring.generate(38)}`)).toBe(true);
});
test("should not match non secrets", async () => {
it("should not match non secrets", async () => {
expect(ApiKey.match("123")).toBe(false);
expect(ApiKey.match("1234567890")).toBe(false);
});
});
describe("lastActiveAt", () => {
test("should update lastActiveAt", async () => {
it("should update lastActiveAt", async () => {
const apiKey = await buildApiKey();
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toBeTruthy();
});
test("should not update lastActiveAt within 5 minutes", async () => {
it("should not update lastActiveAt within 5 minutes", async () => {
const apiKey = await buildApiKey();
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toBeTruthy();
@@ -35,7 +35,7 @@ describe("#ApiKey", () => {
});
describe("findByToken", () => {
test("should find by hash", async () => {
it("should find by hash", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
@@ -44,4 +44,62 @@ describe("#ApiKey", () => {
expect(found?.last4).toEqual(apiKey.value!.slice(-4));
});
});
describe("canAccess", () => {
it("should return true for all resources if no scope", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/collections.create")).toBe(true);
expect(apiKey.canAccess("/api/apiKeys.list")).toBe(true);
});
it("should return false if no matching scope", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/documents.info"],
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
expect(apiKey.canAccess("/api/apiKeys.list")).toBe(false);
});
it("should allow wildcard methods", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/documents.*"],
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/documents.create")).toBe(true);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
it("should allow wildcard namespaces", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/*.info"],
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/documents.create")).toBe(false);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
it("should allow multiple scopes", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/*.info", "/api/collections.list"],
});
expect(apiKey.canAccess("/api/shares.info")).toBe(true);
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/collections.list")).toBe(true);
expect(apiKey.canAccess("/api/documents.create")).toBe(false);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
});
});
+32
View File
@@ -1,4 +1,5 @@
import crypto from "crypto";
import { Matches } from "class-validator";
import { subMinutes } from "date-fns";
import randomstring from "randomstring";
import { InferAttributes, InferCreationAttributes, Op } from "sequelize";
@@ -31,6 +32,7 @@ class ApiKey extends ParanoidModel<
static eventNamespace = "api_keys";
/** The human-readable name of this API key */
@Length({
min: ApiKeyValidation.minNameLength,
max: ApiKeyValidation.maxNameLength,
@@ -39,6 +41,13 @@ class ApiKey extends ParanoidModel<
@Column
name: string;
/** A space-separated list of scopes that this API key has access to */
@Matches(/[\/\.\w\s]*/, {
each: true,
})
@Column(DataType.ARRAY(DataType.STRING))
scope: string[] | null;
/** @deprecated The plain text value of the API key, removed soon. */
@Unique
@Column
@@ -59,10 +68,12 @@ class ApiKey extends ParanoidModel<
@SkipChangeset
last4: string;
/** The date and time when this API key will expire */
@IsDate
@Column
expiresAt: Date | null;
/** The date and time when this API key was last used */
@IsDate
@Column
@SkipChangeset
@@ -156,6 +167,27 @@ class ApiKey extends ParanoidModel<
return this.save({ silent: true });
};
/** Checks if the API key has access to the given path */
canAccess = (path: string) => {
if (!this.scope) {
return true;
}
const resource = path.split("/").pop() ?? "";
const [namespace, method] = resource.split(".");
return this.scope.some((scope) => {
const [scopeNamespace, scopeMethod] = scope
.replace("/api/", "")
.split(".");
return (
scope.startsWith("/api/") &&
(namespace === scopeNamespace || scopeNamespace === "*") &&
(method === scopeMethod || scopeMethod === "*")
);
});
};
}
export default ApiKey;
-2
View File
@@ -36,8 +36,6 @@ class Attachment extends IdModel<
InferAttributes<Attachment>,
Partial<InferCreationAttributes<Attachment>>
> {
static eventNamespace = "attachments";
@Length({
max: 4096,
msg: "key must be 4096 characters or less",
+1 -2
View File
@@ -166,8 +166,6 @@ class Collection extends ParanoidModel<
InferAttributes<Collection>,
Partial<InferCreationAttributes<Collection>>
> {
static eventNamespace = "collections";
@SimpleLength({
min: 10,
max: 10,
@@ -340,6 +338,7 @@ class Collection extends ParanoidModel<
createdById: model.createdById,
},
transaction: options.transaction,
hooks: false,
});
}
-2
View File
@@ -42,8 +42,6 @@ class Comment extends ParanoidModel<
InferAttributes<Comment>,
Partial<InferCreationAttributes<Comment>>
> {
static eventNamespace = "comments";
@TextLength({
max: CommentValidation.maxLength,
msg: `Comment must be less than ${CommentValidation.maxLength} characters`,

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