Compare commits

...

56 Commits

Author SHA1 Message Date
Salihu 98445e9996 option to show more 'shared with me' documents 2025-10-31 21:09:55 +01:00
Tom Moor da6a449cf3 fix: Double 'selected' state on menus when hovering as it opens (#10532) 2025-10-31 01:50:08 +00:00
Tom Moor 4631b5ccaa chore: Remove ability to collapse sidebar on shared links (#10531)
* fix: Remove ability to collapse sidebar on shared links

* fix: Existing collapsed sidebars should be forced open
2025-10-31 01:17:03 +00:00
Tom Moor 4d5895d2a8 fix: Extra lines before template application (#10528) 2025-10-30 21:16:33 -04:00
Tom Moor 3543fafee3 fix: Input in embed toolbar grabs focus (#10530) 2025-10-31 01:11:29 +00:00
Tom Moor e77cdc2903 fix: emdash replacement conflicts with horizontal rule (#10515) 2025-10-30 01:59:47 +00:00
Tom Moor ecba11b786 v1.0.1 2025-10-28 23:17:50 -04:00
Tom Moor 6d13347806 fix: Cannot resize embed on collection overview (#10498)
fix: Toolbar too small on embed link editor
2025-10-28 21:12:55 -04:00
Tom Moor 36773febd2 fix: YouTube referrerpolicy requirements seem to have tightened (#10503) 2025-10-28 21:12:44 -04:00
Tom Moor fa8d82d82a fix: Restore uuid package on frontend (#10491)
* fix: Restore uuid package on frontend

* Remove legacy moduleNameMapper

* Add lint rule

* lint - getRandomValues can be used without SSL

* Update Comment.ts
2025-10-28 08:13:48 -04:00
Tom Moor cc6d2dc471 fix: Missizing of math (#10494) 2025-10-28 08:13:39 -04:00
Tom Moor 5035ad2027 fix: Pin to node 22.21.0 (#10496) 2025-10-28 08:13:28 -04:00
Apoorv Mishra 06ec6fdfbb Enable commenting on images (#10474)
* feat: enable commenting on image nodes

* chore: make anchorPlugin a top level plugin

* fix: className

* fix: review

* fix: tsc

* fix: checks

* Tweak menu order to match

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-28 11:34:40 +05:30
dependabot[bot] acc8d99ca0 chore(deps): bump the babel group with 5 updates (#10484)
Bumps the babel group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.28.4` | `7.28.5` |
| [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring) | `7.28.0` | `7.28.5` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.28.3` | `7.28.5` |
| [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) | `7.27.1` | `7.28.5` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.27.1` | `7.28.5` |


Updates `@babel/core` from 7.28.4 to 7.28.5
- [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.28.5/packages/babel-core)

Updates `@babel/plugin-transform-destructuring` from 7.28.0 to 7.28.5
- [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.28.5/packages/babel-plugin-transform-destructuring)

Updates `@babel/preset-env` from 7.28.3 to 7.28.5
- [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.28.5/packages/babel-preset-env)

Updates `@babel/preset-react` from 7.27.1 to 7.28.5
- [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.28.5/packages/babel-preset-react)

Updates `@babel/preset-typescript` from 7.27.1 to 7.28.5
- [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.28.5/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.28.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-version: 7.28.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.28.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-react"
  dependency-version: 7.28.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-version: 7.28.5
  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-10-27 23:12:28 -04:00
dependabot[bot] 7da3108412 chore(deps): bump validator from 13.15.15 to 13.15.20 (#10490)
Bumps [validator](https://github.com/validatorjs/validator.js) from 13.15.15 to 13.15.20.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.15.15...13.15.20)

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.20
  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-10-28 03:03:06 +00:00
dependabot[bot] 7e56d04285 chore(deps): bump ioredis from 5.7.0 to 5.8.2 (#10483)
Bumps [ioredis](https://github.com/luin/ioredis) from 5.7.0 to 5.8.2.
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.7.0...v5.8.2)

---
updated-dependencies:
- dependency-name: ioredis
  dependency-version: 5.8.2
  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-10-27 22:55:16 -04:00
dependabot[bot] 3987b7de3d chore(deps): bump the aws group with 5 updates (#10485)
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.913.0` | `3.917.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.913.0` | `3.917.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.913.0` | `3.917.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.913.0` | `3.917.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.911.0` | `3.916.0` |


Updates `@aws-sdk/client-s3` from 3.913.0 to 3.917.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.917.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.913.0 to 3.917.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.917.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.913.0 to 3.917.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.917.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.913.0 to 3.917.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.917.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.911.0 to 3.916.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.916.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.917.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.917.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.917.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.917.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.916.0
  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-10-27 22:55:01 -04:00
dependabot[bot] 6daed33b4a chore(deps-dev): bump @types/readable-stream from 4.0.21 to 4.0.22 (#10486)
Bumps [@types/readable-stream](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/readable-stream) from 4.0.21 to 4.0.22.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/readable-stream)

---
updated-dependencies:
- dependency-name: "@types/readable-stream"
  dependency-version: 4.0.22
  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-10-27 22:54:53 -04:00
Tom Moor 3551d16bd8 v1.0.0 2025-10-26 11:36:27 -04:00
Translate-O-Tron 641c0da603 New Crowdin updates (#10347)
* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]
2025-10-25 20:16:11 -04:00
Tom Moor 7768273255 fix: Replace base64 encoded images in documents.update (#10402)
* fix: Replace base64 encoded images in documents.update

* isInternalUrl

* b64 only
2025-10-26 00:11:02 +00:00
Tom Moor 9cadcc668c fix: Update email magic links (#10471)
* fix: Update email magic links to check IP within time limit rather than usage

* Add option to force OTP method
2025-10-25 12:23:45 -04:00
Tom Moor adc11aee9f chore: More sidebar performance fixes (#10470) 2025-10-25 15:05:19 +00:00
AnastasiyaHladina 7ab247f367 chore: update minimal Node.js version (#10403) 2025-10-24 21:44:27 -04:00
huiseo 9ec5c473f1 fix: prevent list conversion inside heading nodes (#10462)
* fix: prevent list conversion inside heading nodes

Fixes a bug where typing list syntax (e.g., "1. ", "* ", "[ ]")
inside heading nodes would incorrectly trigger list conversion.

Previously, when a user selected H1 from the "/" menu and typed
"1. " followed by a space, the OrderedList inputRule would attempt
to convert the heading into an ordered list, causing a conflict
since headings can only contain inline content.

Changes:
- Add isInHeading utility to detect if selection is inside a heading
- Create safeWrappingInputRule wrapper that prevents list conversion
  when inside heading nodes
- Apply the fix to OrderedList, BulletList, and CheckboxList nodes

This ensures that list markdown syntax is preserved as plain text
when typed within headings, matching expected editor behavior.

* refactor: extract listWrappingInputRule to shared helper

Refactored duplicated safeWrappingInputRule implementations across
BulletList, OrderedList, and CheckboxList into a single shared helper
function named listWrappingInputRule in shared/editor/lib/listInputRule.ts.

This reduces code duplication and follows the same pattern as other
input rule helpers like markInputRule.

Changes:
- Create shared/editor/lib/listInputRule.ts with listWrappingInputRule
- Update BulletList.ts to use shared helper
- Update OrderedList.ts to use shared helper
- Update CheckboxList.ts to use shared helper
- Restore .env.development file

Co-Authored-By: huiseo <hui.seo@gmail.com>
2025-10-23 20:23:47 -04:00
Tom Moor 02bdb2e464 fix: Render-per-model type, 4x improvement on perf (#10465)
* fix: Render-per-model type, 4x improvement on perf

* fix: Sidebar CollectionLinkChildren render when @mention changes
2025-10-23 20:23:38 -04:00
codegen-sh[bot] 77d50f8323 Add disableMentions option for individual groups (#10459)
* Add disableMentions option for groups

- Add database migration to add disableMentions column to groups table
- Update server-side Group model with new field
- Add disableMentions to group create/update API schemas and endpoints
- Update client-side Group model with new field
- Add checkbox to EditGroupDialog for disabling mentions
- Filter out groups with disableMentions=true from mention suggestions
- Prevent notifications for groups with disabled mentions

* Fix TypeScript error in GroupDialogs checkbox handler

- Add properly typed handleDisableMentionsChange callback
- Replace inline onChange handler with typed callback
- Fixes TS2339 error: Property 'checked' does not exist on type 'EventTarget'

* UI tweaks

* Add groups to suggestions endpoint

* Remove mentionableData

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-23 08:24:22 -04:00
Tom Moor 76691e8aaa fix: Add yet another guard against crawlers consuming magic links (#10457) 2025-10-23 08:24:10 -04:00
Tom Moor 633d41e67f fix: Fallback to any Linear integration (#10458)
* Fallback to any integration

* fix: Cannot unfurl Linear links without creator
2025-10-23 08:23:54 -04:00
Tom Moor 3db845b395 fix: Protect against empty content passed to Backticks component (#10456) 2025-10-22 21:05:10 -04:00
codegen-sh[bot] 3269eacf68 Add hover card for group mentions (#10432)
* Add hover card for group mentions

- Add Group type to UnfurlResourceType enum
- Create HoverPreviewGroup component following HoverUser pattern
- Add server-side support for group unfurling in URLs route
- Display group name, member count, and member avatars in hover card
- Implement presentGroup function in unfurl presenter

Fixes #10418

* Fix TypeScript errors in group hover card implementation

- Make presentGroup async to properly handle group.memberCount Promise
- Update presentUnfurl to await presentGroup result
- Fix Facepile users prop by creating User-like objects with required properties
- Add User import to HoverPreviewGroup component

Fixes TypeScript compilation errors:
- TS2322: Type mismatch in HoverPreviewGroup.tsx
- TS2362: Arithmetic operation type error in unfurl.ts
- TS2322: Promise<number> not assignable to number in unfurl.ts

* tweaks

* tweaks

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-22 21:04:58 -04:00
dependabot[bot] eef2ea4347 chore(deps): bump koa from 3.0.1 to 3.0.3 (#10444)
Bumps [koa](https://github.com/koajs/koa) from 3.0.1 to 3.0.3.
- [Release notes](https://github.com/koajs/koa/releases)
- [Changelog](https://github.com/koajs/koa/blob/master/History.md)
- [Commits](https://github.com/koajs/koa/compare/v3.0.1...v3.0.3)

---
updated-dependencies:
- dependency-name: koa
  dependency-version: 3.0.3
  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-10-22 19:32:57 -04:00
Tom Moor a2ce13a7dd chore: Improve email sign-in debugging (#10455) 2025-10-22 23:32:25 +00:00
Tom Moor ff13f1a452 Update API responses to 204 (#10441)
* shares.info

* subscriptions and pins to 204
2025-10-22 17:48:24 -04:00
Tom Moor a5d065e5ec chore: Annotate delayed notifs (#10447) 2025-10-22 17:48:12 -04:00
Tom Moor fc6152bd55 fix: Simplify logic for suppressing markdown copy (#10450) 2025-10-22 17:47:47 -04:00
Tom Moor 06d4d7e893 chore: Improve Redis retry behavior (#10440) 2025-10-20 23:54:13 -04:00
dependabot[bot] a85f36d896 chore(deps-dev): bump discord-api-types from 0.38.20 to 0.38.30 (#10435)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.38.20 to 0.38.30.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.38.20...0.38.30)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-version: 0.38.30
  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-10-20 20:18:30 -04:00
dependabot[bot] 5231318e55 chore(deps): bump patch-package from 8.0.0 to 8.0.1 (#10434)
Bumps [patch-package](https://github.com/ds300/patch-package) from 8.0.0 to 8.0.1.
- [Release notes](https://github.com/ds300/patch-package/releases)
- [Changelog](https://github.com/ds300/patch-package/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ds300/patch-package/commits)

---
updated-dependencies:
- dependency-name: patch-package
  dependency-version: 8.0.1
  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-10-20 20:18:23 -04:00
dependabot[bot] 916032508c chore(deps-dev): bump react-refresh from 0.17.0 to 0.18.0 (#10436)
Bumps [react-refresh](https://github.com/facebook/react/tree/HEAD/packages/react) from 0.17.0 to 0.18.0.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/react)

---
updated-dependencies:
- dependency-name: react-refresh
  dependency-version: 0.18.0
  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-10-20 20:18:15 -04:00
dependabot[bot] 1a3478a228 chore(deps): bump the aws group with 5 updates (#10438)
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.908.0` | `3.913.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.908.0` | `3.913.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.908.0` | `3.913.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.908.0` | `3.913.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.908.0` | `3.911.0` |


Updates `@aws-sdk/client-s3` from 3.908.0 to 3.913.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.913.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.908.0 to 3.913.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.913.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.908.0 to 3.913.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.913.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.908.0 to 3.913.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.913.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.908.0 to 3.911.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.911.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.913.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.913.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.913.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.913.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.911.0
  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-10-20 20:18:06 -04:00
Tom Moor 1028edaa03 Rounded display of tables (#10421) 2025-10-20 07:58:25 -04:00
Translate-O-Tron 6a736072f0 Add translation changes from outline-cloud (#10427)
Co-authored-by: GitHub Action <action@github.com>
2025-10-19 22:34:40 -04:00
Tom Moor 94f302f712 Revert "Update en_US translations from upstream (#10425)" (#10426)
This reverts commit d2ef7e770d.
2025-10-19 22:02:11 -04:00
Translate-O-Tron d2ef7e770d Update en_US translations from upstream (#10425)
Co-authored-by: GitHub Action <action@github.com>
2025-10-19 21:50:48 -04:00
Tom Moor 323094ce57 fix: min-width applied to all floating toolbars (#10424) 2025-10-20 00:38:45 +00:00
Tom Moor 0e596f61c8 fix: Various React warnings (#10423) 2025-10-19 20:06:09 -04:00
Tom Moor a23888f5d6 fix: Query error in export task (#10422) 2025-10-19 23:33:39 +00:00
Salihu 515e160bdb feat: Allow editing image source URLs (#10258)
* allow users edit image links

* use menu dropdown for image replacement options

* copy

* keep editing state in selection toolbar

* avoid overly broad types

* use fixed toolbar width

* tweaks

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-19 16:56:51 -04:00
github-actions[bot] c853063d1f chore: Compressed inefficient images automatically (#10420)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-10-19 16:50:39 -04:00
Salihu e86593f234 feat: add group mentions (#10331)
* add group mentions

* group mention functionality

* add notification test

* fix: Group icon in mention menu

* language

* toast message

* fix: Group icon in mention menu light mode color

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-19 15:40:10 -04:00
Tom Moor 285b770b3d chore: Convert SelectionToolbar to editor widget pattern (#10414)
* refactor

* fix: Restore toolbar arrow
fix: Delayed width calculation
fix: Unused menuBorder theme prop
2025-10-18 20:34:19 -04:00
Tom Moor 2c27ef9c2c chore: Restore menu safe-area (#10415)
* chore: Restore menu safe-area

Removed in #10219

* Remove unneccessary dev translations
2025-10-18 19:33:35 -04:00
Tom Moor 3704dc2a4d fix: Combination of <br> and inline nodes in table cell is not imported correctly (#10416) 2025-10-18 19:30:45 -04:00
Tom Moor d37422ab8a fix: Creating new doc offline in sidebar leaves corrupt state in UI (#10412) 2025-10-18 10:43:03 -04:00
Tom Moor a75af8759b fix: Horizontal rule menu appears in read-only editor (#10413) 2025-10-18 10:33:17 -04:00
174 changed files with 6188 additions and 2191 deletions
+2 -4
View File
@@ -20,8 +20,7 @@
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
@@ -48,8 +47,7 @@
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
+8 -8
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:22-slim AS runner
FROM node:22.21.0-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
@@ -16,9 +16,9 @@ ENV NODE_ENV=production
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
adduser --uid 1001 --ingroup nodejs nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server
@@ -29,13 +29,13 @@ COPY --from=base $APP_PATH/package.json ./package.json
# Install wget to healthcheck the server
RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
VOLUME /var/lib/outline/data
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:22 AS deps
FROM node:22.21.0 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.87.4
Licensed Work: Outline 1.0.1
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-09-18
Change Date: 2029-10-29
Change License: Apache License, Version 2.0
+7
View File
@@ -5,6 +5,13 @@
{
"files": ["**/*.{jsx,tsx}"],
"rules": {
"no-restricted-globals": [
"error",
{
"name": "crypto",
"message": "Do not use, does not work in environments without SSL."
}
],
"no-restricted-imports": [
"error",
{
+16
View File
@@ -176,6 +176,21 @@ export const toggleDebugLogging = createAction({
},
});
export const toggleDebugSafeArea = createAction({
name: () => "Toggle menu safe area debugging",
icon: <ToolsIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: ({ stores }) => {
stores.ui.toggleDebugSafeArea();
toast.message(
stores.ui.debugSafeArea
? "Menu safe area debugging enabled"
: "Menu safe area debugging disabled"
);
},
});
export const toggleFeatureFlag = createAction({
name: "Toggle feature flag",
icon: <BeakerIcon />,
@@ -209,6 +224,7 @@ export const developer = createAction({
children: [
copyId,
toggleDebugLogging,
toggleDebugSafeArea,
toggleFeatureFlag,
createToast,
createTestUsers,
+7 -6
View File
@@ -1,4 +1,5 @@
import { LocationDescriptor } from "history";
import { v4 as uuidv4 } from "uuid";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import { Optional } from "utility-types";
@@ -45,7 +46,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
return definition.perform?.(context);
}
: undefined,
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -201,7 +202,7 @@ export function createActionV2(
return definition.perform(context);
}
: () => {},
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -212,7 +213,7 @@ export function createInternalLinkActionV2(
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -223,7 +224,7 @@ export function createExternalLinkActionV2(
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -234,7 +235,7 @@ export function createActionV2WithChildren(
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -251,7 +252,7 @@ export function createRootMenuAction(
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: crypto.randomUUID(),
id: uuidv4(),
type: "action",
variant: "action_with_children",
name: "root_action",
+1
View File
@@ -26,6 +26,7 @@ export function GroupAvatar({
return (
<Squircle color={color ?? theme.text} size={size} className={className}>
<GroupIcon
data-fixed-color
color={backgroundColor ?? theme.background}
size={size * 0.75}
/>
+4 -2
View File
@@ -83,13 +83,15 @@ function EditableTitle(
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
setIsEditing(false);
} catch (error) {
setValue(originalValue);
setValue(value);
setIsEditing(true);
toast.error(error.message);
throw error;
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
},
[originalValue, value, onCancel, onSubmit, isSubmitting]
+11 -3
View File
@@ -13,6 +13,7 @@ import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewGroup from "./HoverPreviewGroup";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
@@ -132,6 +133,13 @@ const HoverPreviewDesktop = observer(
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Group ? (
<HoverPreviewGroup
ref={cardRef}
name={data.name}
memberCount={data.memberCount}
users={data.users}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
@@ -295,10 +303,10 @@ const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
&:before {
border: 8px solid transparent;
${({ direction, theme }) =>
${({ direction }) =>
direction === Direction.UP
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
? `border-bottom-color: rgba(0, 0, 0, 0.1)`
: `border-top-color: rgba(0, 0, 0, 0.1)`};
${({ direction }) =>
direction === Direction.UP ? "right: -1px" : "left: -1px"};
}
@@ -0,0 +1,59 @@
import * as React from "react";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import User from "~/models/User";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import {
Preview,
Title,
Info,
Card,
CardContent,
Description,
} from "./Components";
import ErrorBoundary from "../ErrorBoundary";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
{ name, memberCount, users }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
<Preview as="div">
<Card fadeOut={false} ref={ref}>
<CardContent>
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
<Flex column gap={2} align="start">
<Title>{name}</Title>
<Info>
{memberCount === 1 ? "1 member" : `${memberCount} members`}
</Info>
{users.length > 0 && (
<Description>
<Facepile
users={users.map(
(member) =>
({
id: member.id,
name: member.name,
avatarUrl: member.avatarUrl,
color: member.color,
initial: member.name ? member.name[0] : "?",
}) as User
)}
overflow={Math.max(0, memberCount - users.length)}
limit={MAX_AVATAR_DISPLAY}
/>
</Description>
)}
</Flex>
</ErrorBoundary>
</CardContent>
</Card>
</Preview>
);
});
export default HoverPreviewGroup;
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
{ avatarUrl, name, lastActive, color, email }: Props,
{ avatarUrl, name, lastActive, color }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
@@ -25,7 +25,6 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
/>
<Flex column gap={2} justify="center">
<Title>{name}</Title>
{email && <Info>{email}</Info>}
<Info>{lastActive}</Info>
</Flex>
</Flex>
@@ -2,7 +2,7 @@ import * as React from "react";
import useMeasure from "react-use-measure";
export const MeasuredContainer = <T extends React.ElementType>({
as: As,
as: As = "div",
name,
children,
...rest
+7 -1
View File
@@ -11,9 +11,12 @@ import {
} from "~/components/primitives/Menu";
import * as Components from "~/components/primitives/components/Menu";
import { MenuItem } from "~/types";
import { MouseSafeArea } from "~/components/MouseSafeArea";
import { createRef } from "react";
export function toMenuItems(items: MenuItem[]) {
const filteredItems = filterMenuItems(items);
const parentRef = createRef<HTMLDivElement>();
if (!filteredItems.length) {
return null;
@@ -88,7 +91,10 @@ export function toMenuItems(items: MenuItem[]) {
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent>{submenuItems}</SubMenuContent>
<SubMenuContent ref={parentRef}>
<MouseSafeArea parentRef={parentRef} />
{submenuItems}
</SubMenuContent>
</SubMenu>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
type Positions = {
/** Sub-menu x */
x: number;
/** Sub-menu y */
y: number;
/** Sub-menu height */
h: number;
/** Sub-menu width */
w: number;
/** Mouse x */
mouseX: number;
/** Mouse y */
mouseY: number;
};
/**
* Component to cover the area between the mouse cursor and the sub-menu, to
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export const MouseSafeArea = observer(function MouseSafeArea_(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
x = 0,
y = 0,
height: h = 0,
width: w = 0,
} = props.parentRef.current?.getBoundingClientRect() || {};
const { ui } = useStores();
const [mouseX, mouseY] = useMousePosition();
const [isVisible, setIsVisible] = React.useState(true);
const positions = { x, y, h, w, mouseX, mouseY };
const distance = Math.abs(mouseX - x);
const prevDistance = usePrevious(distance) ?? distance;
// Hide the safe area if the mouse is moving _away_ from the menu
React.useEffect(() => {
if (distance > prevDistance) {
setIsVisible(false);
} else if (distance < prevDistance) {
setIsVisible(true);
}
}, [distance, prevDistance]);
if (!isVisible) {
return null;
}
return (
<div
style={{
position: "absolute",
top: 0,
backgroundColor: ui.debugSafeArea ? "rgba(255,0,0,0.2)" : undefined,
right: getRight(positions),
left: getLeft(positions),
height: h,
width: getWidth(positions),
clipPath: getClipPath(positions),
}}
/>
);
});
const buffer = 10;
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX + buffer, buffer) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w) + buffer, buffer) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w - buffer), buffer) + "px"
: Math.max(x - mouseX + buffer, buffer) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${
(100 * (mouseY - y)) / h + 5
}%, 100% ${(100 * (mouseY - y)) / h - buffer}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - buffer}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
+2 -4
View File
@@ -47,7 +47,7 @@ function SharedSidebar({ share }: Props) {
}
return (
<StyledSidebar $hoverTransition={!teamAvailable}>
<StyledSidebar $hoverTransition={!teamAvailable} canResize={false}>
{teamAvailable && (
<SidebarButton
title={team.name}
@@ -57,9 +57,7 @@ function SharedSidebar({ share }: Props) {
onClick={() =>
history.push(user ? homePath() : sharedModelPath(shareId))
}
>
<ToggleSidebar />
</SidebarButton>
/>
)}
<ScrollContainer topShadow flex>
<TopSection>
+11 -7
View File
@@ -25,13 +25,15 @@ import { useTranslation } from "react-i18next";
const ANIMATION_MS = 250;
type Props = {
children: React.ReactNode;
hidden?: boolean;
/** Whether the sidebar can be resized and collapsed, defaults to true. */
canResize?: boolean;
className?: string;
children: React.ReactNode;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children, hidden = false, className }: Props,
{ children, hidden = false, canResize = true, className }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const [isCollapsing, setCollapsing] = React.useState(false);
@@ -43,7 +45,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobile = useMobile();
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed;
const collapsed = ui.sidebarIsClosed && canResize;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -254,10 +256,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
</SidebarButton>
</AccountMenu>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
{canResize && (
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
)}
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</TooltipProvider>
@@ -86,27 +86,33 @@ const CollectionLink: React.FC<Props> = ({
editableTitleRef.current?.setIsEditing(true);
}, [editableTitleRef]);
const newChildTitleRef = React.useRef<RefHandle>(null);
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);
try {
newChildTitleRef.current?.setIsEditing(false);
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.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
},
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
);
@@ -192,6 +198,7 @@ const CollectionLink: React.FC<Props> = ({
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
@@ -22,7 +22,7 @@ function Disclosure({ onClick, root, expanded, ...rest }: Props) {
aria-label={expanded ? t("Collapse") : t("Expand")}
{...rest}
>
<StyledCollapsedIcon expanded={expanded} size={20} />
<StyledCollapsedIcon $expanded={expanded} size={20} />
</Button>
);
}
@@ -52,13 +52,13 @@ const Button = styled(NudeButton)<{ $root?: boolean }>`
`;
const StyledCollapsedIcon = styled(CollapsedIcon)<{
expanded?: boolean;
$expanded?: boolean;
}>`
transition:
opacity 100ms ease,
transform 100ms ease,
fill 50ms !important;
${(props) => !props.expanded && "transform: rotate(-90deg);"};
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
`;
// Enables identifying this component within styled components
@@ -281,30 +281,36 @@ function InnerDocumentLink(
[setExpanded, setCollapsed, hasChildren, expanded]
);
const newChildTitleRef = React.useRef<RefHandle>(null);
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);
try {
newChildTitleRef.current?.setIsEditing(false);
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.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
},
[
documents,
@@ -320,6 +326,62 @@ function InnerDocumentLink(
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
const labelElement = React.useMemo(
() => (
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
ref={editableTitleRef}
/>
),
[title, handleTitleChange, isEditing, setIsEditing, canUpdate]
);
const menuElement = React.useMemo(
() =>
document && !isMoving && !isEditing && !isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<Tooltip content={t("New doc")}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<DocumentMenu
document={document}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined,
[
document,
isMoving,
isEditing,
isDraggingAnyDocument,
can.createChildDocument,
t,
setIsAddingNewChild,
setExpanded,
handleRename,
handleMenuOpen,
handleMenuClose,
]
);
return (
<ActionContextProvider
value={{
@@ -345,17 +407,7 @@ function InnerDocumentLink(
contextAction={contextMenuAction}
to={toPath}
icon={iconElement}
label={
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
ref={editableTitleRef}
/>
}
label={labelElement}
isActive={isActiveCheck}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
@@ -364,35 +416,7 @@ function InnerDocumentLink(
scrollIntoViewIfNeeded={sidebarContext === "collections"}
isDraft={isDraft}
ref={ref}
menu={
document &&
!isMoving &&
!isEditing &&
!isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<Tooltip content={t("New doc")}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<DocumentMenu
document={document}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
menu={menuElement}
/>
</DropToImport>
</div>
@@ -414,6 +438,7 @@ function InnerDocumentLink(
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
@@ -8,19 +8,32 @@ import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { RequestResponse } from "~/hooks/usePaginatedRequest";
import GroupMembership from "~/models/GroupMembership";
import { t } from "i18next";
import { toast } from "sonner";
type Props = {
/** The group to render */
group: Group;
/** The response from the group memberships request */
response: RequestResponse<GroupMembership>;
};
const GroupLink: React.FC<Props> = ({ group }) => {
const GroupLink: React.FC<Props> = ({ group, response }) => {
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = groupSidebarContext(group.id);
const { loading, next, end, error } = response;
const [expanded, setExpanded] = React.useState(
locationSidebarContext === sidebarContext
);
React.useEffect(() => {
if (error) {
toast.error(t("Could not load shared documents"));
}
}, [error, t]);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
@@ -50,6 +63,14 @@ const GroupLink: React.FC<Props> = ({ group }) => {
depth={1}
/>
))}
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={loading}
depth={0}
/>
)}
</Folder>
</SidebarContext.Provider>
</Relative>
+3 -3
View File
@@ -41,7 +41,7 @@ export const Header: React.FC<Props> = ({ id, title, children }: Props) => {
<H3>
<Button onClick={handleClick} disabled={!id}>
{title}
{id && <Disclosure expanded={expanded} size={20} />}
{id && <Disclosure $expanded={expanded} size={20} />}
</Button>
</H3>
{expanded && (firstRender ? children : <Fade>{children}</Fade>)}
@@ -91,12 +91,12 @@ const Button = styled.button`
}
`;
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
const Disclosure = styled(CollapsedIcon)<{ $expanded?: boolean }>`
transition:
opacity 100ms ease,
transform 100ms ease,
fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
opacity: 0;
`;
@@ -30,7 +30,9 @@ function SharedWithMe() {
const history = useHistory();
const locationSidebarContext = useLocationSidebarContext();
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
const gmResponse = usePaginatedRequest<GroupMembership>(
groupMemberships.fetchAll
);
const { loading, next, end, error, page } =
usePaginatedRequest<UserMembership>(userMemberships.fetchPage, {
@@ -108,7 +110,7 @@ function SharedWithMe() {
<Flex column>
<Header id="shared" title={t("Shared with me")}>
{user.groupsWithDocumentMemberships.map((group) => (
<GroupLink key={group.id} group={group} />
<GroupLink key={group.id} group={group} response={gmResponse} />
))}
<Relative>
{reorderProps.isDragging && (
@@ -85,11 +85,8 @@ function StarredDocumentLink({
const { collections, documents } = useStores();
const document = documents.get(documentId);
if (!document) {
return null;
}
const documentCollection = document.collectionId
const documentCollection = document?.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = documentCollection
@@ -97,7 +94,11 @@ function StarredDocumentLink({
: [];
const hasChildDocuments = childDocuments.length > 0;
const displayChildDocuments = expanded && !isDragging;
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
const contextMenuAction = useDocumentMenuAction({ documentId });
if (!document) {
return null;
}
return (
<ActionContextProvider
@@ -7,18 +7,28 @@ export default function useCollectionDocuments(
collection: Collection | undefined,
activeDocument: Document | undefined
) {
const insertDraftDocument = useMemo(
() =>
activeDocument &&
activeDocument.isActive &&
activeDocument.isDraft &&
activeDocument.collectionId === collection?.id &&
!activeDocument.parentDocumentId,
[
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
collection?.id,
]
);
return useMemo(() => {
if (!collection?.sortedDocuments) {
return undefined;
}
const insertDraftDocument =
activeDocument?.isActive &&
activeDocument?.isDraft &&
activeDocument?.collectionId === collection.id &&
!activeDocument?.parentDocumentId;
return insertDraftDocument
return insertDraftDocument && activeDocument
? sortNavigationNodes(
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
collection.sort,
@@ -26,14 +36,9 @@ export default function useCollectionDocuments(
)
: collection.sortedDocuments;
}, [
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
insertDraftDocument,
activeDocument?.asNavigationNode,
collection,
collection?.sortedDocuments,
collection?.id,
collection?.sort,
]);
}
+12 -16
View File
@@ -57,7 +57,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
box-shadow: none;
cursor: var(--pointer);
svg {
svg:not([data-fixed-color]) {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
@@ -66,22 +66,18 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
${(props) =>
!props.disabled &&
`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.$dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.$dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
svg:not([data-fixed-color]) {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
}
`}
+15 -9
View File
@@ -1,3 +1,4 @@
import { useCallback } from "react";
import useDictionary from "~/hooks/useDictionary";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
@@ -13,20 +14,25 @@ function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
),
[]
);
return (
<SuggestionsMenu
{...props}
filterable
trigger="/"
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
)}
renderMenuItem={renderMenuItem}
items={getMenuItems(dictionary, elementRef)}
/>
);
+14 -9
View File
@@ -1,5 +1,5 @@
import capitalize from "lodash/capitalize";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji";
import EmojiMenuItem from "./EmojiMenuItem";
@@ -45,18 +45,23 @@ const EmojiMenu = (props: Props) => {
[search]
);
const renderMenuItem = useCallback(
(item, _index, options) => (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
/>
),
[]
);
return (
<SuggestionsMenu
{...props}
filterable={false}
renderMenuItem={(item, _index, options) => (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
/>
)}
renderMenuItem={renderMenuItem}
items={items}
/>
);
+18 -5
View File
@@ -47,9 +47,19 @@ function usePosition({
}) {
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth ?? 0;
const [menuWidth, setMenuWidth] = React.useState(0);
const menuHeight = 36;
// Measure the menu width after DOM updates to ensure accurate positioning
React.useLayoutEffect(() => {
if (menuRef.current) {
const width = menuRef.current.offsetWidth;
if (width !== menuWidth) {
setMenuWidth(width);
}
}
});
// based on the start and end of the selection calculate the position at
// the center top
let fromPos;
@@ -288,7 +298,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
ref={menuRef}
$offset={position.offset}
style={{
width: props.width,
minWidth: props.width,
maxWidth: `${position.maxWidth}px`,
top: `${position.top}px`,
left: `${position.left}px`,
@@ -309,7 +319,7 @@ type WrapperProps = {
const arrow = (props: WrapperProps) =>
props.arrow
? css`
&::before {
&::after {
content: "";
display: block;
width: 24px;
@@ -317,11 +327,14 @@ const arrow = (props: WrapperProps) =>
transform: translateX(-50%) rotate(45deg);
background: ${s("menuBackground")};
border-radius: 3px;
z-index: -1;
z-index: 0;
position: absolute;
bottom: -3px;
bottom: -2px;
left: calc(50% - ${props.$offset || 0}px);
pointer-events: none;
// clip to show only the bottom right corner
clip-path: polygon(100% 50%, 100% 100%, 50% 100%);
}
`
: "";
@@ -3,71 +3,57 @@ import { Node } from "prosemirror-model";
import { Selection, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
import Flex from "~/components/Flex";
import Tooltip from "~/components/Tooltip";
import Input from "~/editor/components/Input";
import { Dictionary } from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import ToolbarButton from "./ToolbarButton";
type Props = {
node: Node;
view: EditorView;
dictionary: Dictionary;
autoFocus?: boolean;
};
export function EmbedLinkEditor({ node, view, dictionary }: Props) {
const { t } = useTranslation();
const embeds = useEmbeds();
const url = node.attrs.href as string;
export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const url = (node.attrs.href ?? node.attrs.src) as string;
const [localUrl, setLocalUrl] = useState(url);
const moveSelectionToEnd = useCallback(() => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(
state.tr.doc.resolve(state.selection.from),
1,
true
);
const selection = nextSelection ?? TextSelection.create(state.tr.doc, 0);
const selection = nextSelection ?? TextSelection.create(state.tr.doc, 0);
dispatch(state.tr.setSelection(selection));
view.focus();
}, [view]);
const openEmbed = useCallback(() => {
const openLink = useCallback(() => {
window.open(url, "_blank");
}, [url]);
const removeEmbed = useCallback(() => {
const remove = useCallback(() => {
const { state, dispatch } = view;
dispatch(state.tr.deleteSelection());
}, [view]);
const updateEmbed = useCallback(() => {
const matchingEmbed = getMatchingEmbed(embeds, localUrl);
if (!matchingEmbed) {
toast.error(t("Sorry, invalid embed link"));
return;
}
const { state, dispatch } = view;
dispatch(
state.tr.setNodeMarkup(state.selection.from, undefined, {
...node.attrs,
href: localUrl,
})
);
const update = useCallback(() => {
const { state } = view;
const hrefType = node.type.name === "image" ? "src" : "href";
const tr = state.tr.setNodeMarkup(state.selection.from, undefined, {
...node.attrs,
[hrefType]: localUrl,
});
view.dispatch(tr);
moveSelectionToEnd();
}, [t, localUrl, embeds, node, view, moveSelectionToEnd]);
}, [localUrl, node, view, moveSelectionToEnd]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -78,7 +64,7 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
switch (event.key) {
case "Enter": {
event.preventDefault();
updateEmbed();
update();
return;
}
@@ -89,12 +75,13 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
}
}
},
[updateEmbed, moveSelectionToEnd]
[update, moveSelectionToEnd]
);
return (
<Wrapper>
<Input
autoFocus={autoFocus}
value={localUrl}
placeholder={dictionary.pasteLink}
onChange={(e) => setLocalUrl(e.target.value)}
@@ -102,13 +89,19 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
readOnly={!view.editable}
/>
<Tooltip content={dictionary.openLink}>
<ToolbarButton onClick={openEmbed} disabled={!localUrl}>
<ToolbarButton onClick={openLink} disabled={!localUrl}>
<OpenIcon />
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.deleteEmbed}>
<ToolbarButton onClick={removeEmbed}>
<Tooltip
content={
node.type.name === "embed"
? dictionary.deleteEmbed
: dictionary.deleteImage
}
>
<ToolbarButton onClick={remove}>
<TrashIcon />
</ToolbarButton>
</Tooltip>
@@ -121,4 +114,5 @@ const Wrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
min-width: 350px;
`;
+98 -33
View File
@@ -1,5 +1,6 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
@@ -9,13 +10,14 @@ import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import {
DocumentsSection,
UserSection,
CollectionsSection,
GroupSection,
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
@@ -24,6 +26,7 @@ import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import { runInAction } from "mobx";
interface MentionItem extends MenuItem {
attrs: {
@@ -44,7 +47,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = useState(false);
const [items, setItems] = useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users, collections } = useStores();
const { auth, documents, users, collections, groups } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
@@ -52,11 +55,17 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const { loading, request } = useRequest(
useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
const res = await client.post("/suggestions.mention", {
query: search,
limit: maxResultsInSection,
});
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
runInAction(() => {
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
res.data.groups.map(groups.add);
});
}, [search, documents, users, collections])
);
@@ -91,7 +100,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: UserSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: uuidv4(),
type: MentionType.User,
modelId: user.id,
actorId,
@@ -99,6 +108,32 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
},
}) as MentionItem
)
.concat(
groups
.findByQuery(search, { maxResults: maxResultsInSection })
.map((group) => ({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24, marginRight: 4 }}
>
<GroupAvatar group={group} size={AvatarSize.Small} />
</Flex>
),
title: group.name,
section: GroupSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Group,
modelId: group.id,
actorId,
label: group.name,
},
}))
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
@@ -123,7 +158,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: DocumentsSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: uuidv4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
@@ -151,7 +186,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: CollectionsSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: uuidv4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
@@ -171,9 +206,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
priority: -1,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: uuidv4(),
type: MentionType.Document,
modelId: crypto.randomUUID(),
modelId: uuidv4(),
actorId,
label: search,
},
@@ -183,7 +218,17 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
groups,
collections,
]);
const handleSelect = useCallback(
async (item: MentionItem) => {
@@ -196,29 +241,57 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
if (!documentId) {
return;
}
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
userId: item.attrs.modelId,
});
if (!res.data.length) {
const user = users.get(item.attrs.modelId);
if (item.attrs.type === MentionType.User) {
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
userId: item.attrs.modelId,
});
if (!res.data.length) {
const user = users.get(item.attrs.modelId);
toast.message(
t(
"{{ userName }} won't be notified, as they do not have access to this document",
{
userName: item.attrs.label,
}
),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
duration: 10000,
}
);
}
} else if (item.attrs.type === MentionType.Group) {
const group = groups.get(item.attrs.modelId);
toast.message(
t(
"{{ userName }} won't be notified, as they do not have access to this document",
`Members of "{{ groupName }}" that have access to this document will be notified`,
{
userName: item.attrs.label,
groupName: item.attrs.label,
}
),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
icon: group ? <GroupAvatar group={group} /> : undefined,
duration: 10000,
}
);
}
},
[t, users, documentId]
[t, users, documentId, groups]
);
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
subtitle={item.subtitle}
title={item.title}
icon={item.icon}
/>
),
[]
);
// Prevent showing the menu until we have data otherwise it will be positioned
@@ -234,15 +307,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
filterable={false}
search={search}
onSelect={handleSelect}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
subtitle={item.subtitle}
title={item.title}
icon={item.icon}
/>
)}
renderMenuItem={renderMenuItem}
items={items}
/>
);
+17 -11
View File
@@ -1,6 +1,7 @@
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { EmailIcon, LinkIcon } from "outline-icons";
import React from "react";
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
@@ -26,6 +27,18 @@ type Props = Omit<
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const items = useItems({ pastedText, embeds });
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.title}
icon={item.icon}
/>
),
[]
);
if (!items) {
props.onClose();
return null;
@@ -36,14 +49,7 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
{...props}
trigger=""
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.title}
icon={item.icon}
/>
)}
renderMenuItem={renderMenuItem}
items={items}
/>
);
@@ -96,11 +102,11 @@ function useItems({
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: crypto.randomUUID(),
id: uuidv4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: crypto.randomUUID(),
modelId: uuidv4(),
actorId: user?.id,
},
appendSpace: true,
+50 -82
View File
@@ -1,11 +1,9 @@
import some from "lodash/some";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getColumnIndex,
@@ -17,7 +15,6 @@ import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import getAttachmentMenuItems from "../menus/attachment";
import getCodeMenuItems from "../menus/code";
import getDividerMenuItems from "../menus/divider";
@@ -29,71 +26,33 @@ import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import { useEditor } from "./EditorContext";
import { EmbedLinkEditor } from "./EmbedLinkEditor";
import { MediaLinkEditor } from "./MediaLinkEditor";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
type Props = {
/** Whether the text direction is right-to-left */
rtl: boolean;
/** Whether the current document is a template */
isTemplate: boolean;
/** Whether the toolbar is currently active/visible */
isActive: boolean;
/** The current selection */
selection?: Selection;
/** Whether the editor is in read-only mode */
readOnly?: boolean;
/** Whether the user has permission to add comments */
canComment?: boolean;
/** Whether the user has permission to update the document */
canUpdate?: boolean;
onOpen: () => void;
onClose: () => void;
/** Callback function when a link is clicked */
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
};
function useIsActive(state: EditorState) {
const { selection, doc } = state;
if (isMarkActive(state.schema.marks.link)(state)) {
return true;
}
if (
(isNodeActive(state.schema.nodes.code_block)(state) ||
isNodeActive(state.schema.nodes.code_fence)(state)) &&
selection.from > 0
) {
return true;
}
if (isInNotice(state) && selection.from > 0) {
return true;
}
if (!selection || selection.empty) {
return false;
}
if (selection instanceof NodeSelection && selection.node.type.name === "hr") {
return true;
}
if (
selection instanceof NodeSelection &&
["image", "attachment", "embed"].includes(selection.node.type.name)
) {
return true;
}
if (selection instanceof NodeSelection) {
return false;
}
const selectionText = doc.cut(selection.from, selection.to).textContent;
if (selection instanceof TextSelection && !selectionText) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
return some(nodes, (n) => n.content.size);
}
function useIsDragging() {
const [isDragging, setDragging, setNotDragging] = useBoolean();
useEventListener("dragstart", setDragging);
@@ -102,25 +61,19 @@ function useIsDragging() {
return isDragging;
}
export default function SelectionToolbar(props: Props) {
const { onClose, readOnly, onOpen } = props;
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, commands } = useEditor();
const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useMobile();
const isActive = useIsActive(view.state) || isMobile;
const isActive = props.isActive || isMobile;
const isDragging = useIsDragging();
const previousIsActive = usePrevious(isActive);
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
React.useEffect(() => {
// Trigger callbacks when the toolbar is opened or closed
if (previousIsActive && !isActive) {
onClose();
}
if (!previousIsActive && isActive) {
onOpen();
}
}, [isActive, onClose, onOpen, previousIsActive]);
setIsEditingImgUrl(false);
}, [isActive]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
@@ -143,6 +96,8 @@ export default function SelectionToolbar(props: Props) {
return;
}
setIsEditingImgUrl(false);
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
@@ -154,7 +109,7 @@ export default function SelectionToolbar(props: Props) {
return () => {
window.removeEventListener("mouseup", handleClickOutside);
};
}, [isActive, previousIsActive, readOnly, view]);
}, [isActive, readOnly, view]);
const handleOnSelectLink = ({
href,
@@ -176,14 +131,14 @@ export default function SelectionToolbar(props: Props) {
);
};
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
if (isDragging) {
return null;
}
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
@@ -205,19 +160,22 @@ export default function SelectionToolbar(props: Props) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelected(state)) {
items = readOnly ? [] : getTableMenuItems(state, dictionary);
items = getTableMenuItems(state, readOnly, dictionary);
} else if (colIndex !== undefined) {
items = readOnly
? []
: getTableColMenuItems(state, colIndex, rtl, dictionary);
items = getTableColMenuItems(state, readOnly, dictionary, {
index: colIndex,
rtl,
});
} else if (rowIndex !== undefined) {
items = readOnly ? [] : getTableRowMenuItems(state, rowIndex, dictionary);
items = getTableRowMenuItems(state, readOnly, dictionary, {
index: rowIndex,
});
} else if (isImageSelection) {
items = readOnly ? [] : getImageMenuItems(state, dictionary);
items = getImageMenuItems(state, readOnly, dictionary);
} else if (isAttachmentSelection) {
items = readOnly ? [] : getAttachmentMenuItems(state, dictionary);
items = getAttachmentMenuItems(state, readOnly, dictionary);
} else if (isDividerSelection) {
items = getDividerMenuItems(state, dictionary);
items = getDividerMenuItems(state, readOnly, dictionary);
} else if (readOnly) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
@@ -252,6 +210,9 @@ export default function SelectionToolbar(props: Props) {
const showLinkToolbar =
link && link.from === selection.from && link.to === selection.to;
const isEditingMedia =
isEmbedSelection || (isImageSelection && isEditingImgUrl);
return (
<FloatingToolbar
align={align}
@@ -270,15 +231,22 @@ export default function SelectionToolbar(props: Props) {
onClickLink={props.onClickLink}
onSelectLink={handleOnSelectLink}
/>
) : isEmbedSelection ? (
<EmbedLinkEditor
) : isEditingMedia ? (
<MediaLinkEditor
key={`embed-${selection.from}`}
node={(selection as NodeSelection).node}
node={selection.node}
view={view}
dictionary={dictionary}
autoFocus={isEditingImgUrl}
/>
) : (
<ToolbarMenu items={items} {...rest} />
<ToolbarMenu
items={items}
{...rest}
handlers={{
editImageUrl: () => setIsEditingImgUrl(true),
}}
/>
)}
</FloatingToolbar>
);
+5 -1
View File
@@ -641,6 +641,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
};
const handleOnClick = () => {
handleClickItem(item);
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
@@ -657,7 +661,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: () => handleClickItem(item),
onClick: handleOnClick,
})}
</ListItem>
</React.Fragment>
@@ -92,4 +92,4 @@ const Shortcut = styled.span<{ $active?: boolean }>`
text-align: right;
`;
export default SuggestionsMenuItem;
export default React.memo(SuggestionsMenuItem);
+21 -7
View File
@@ -20,15 +20,20 @@ import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
handlers?: Record<string, (...args: any[]) => void>;
};
/*
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
function ToolbarDropdown(props: {
active: boolean;
item: MenuItem;
handlers?: Record<string, Function>;
}) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item } = props;
const { item, handlers } = props;
const { state } = view;
const items: TMenuItem[] = useMemo(() => {
@@ -37,11 +42,19 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
return;
}
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
if (commands[menuItem.name]) {
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (handlers && handlers[menuItem.name]) {
handlers[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
}
};
return item.children
@@ -128,6 +141,7 @@ function ToolbarMenu(props: Props) {
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
handlers={props.handlers}
active={isActive && !item.label}
item={item}
/>
@@ -1,6 +1,5 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { isList } from "@shared/editor/queries/isList";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
/**
@@ -19,33 +18,32 @@ export default class ClipboardTextSerializer extends Extension {
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice, view) => {
const isMultiline = slice.content.childCount > 1;
clipboardTextSerializer: (slice) => {
// Check if the only node is a code block
const isSingleCodeBlock =
slice.content.childCount === 1 &&
(slice.content.firstChild?.type.name === "code_block" ||
slice.content.firstChild?.type.name === "code_fence");
// This is a cheap way to determine if the content is "complex",
// aka it has multiple marks or formatting. In which case we'll use
// markdown formatting
const hasMultipleListItems = slice.content.content
.filter((node) => node.content.content.length > 1)
.some((node) => isList(node, view.state.schema));
const hasMultipleBlockTypes =
[
...new Set(
slice.content.content
.filter((node) => node.content.content.length > 1)
.map((node) => node.type.name)
),
].length > 1;
const copyAsMarkdown =
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
// Check if the only mark is a code mark
const marks = new Set<string>();
slice.content.descendants((node) => {
node.marks.forEach((mark) => marks.add(mark.type.name));
});
const hasOnlyCodeMark =
marks.size === 1 && marks.has("code_inline");
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
softBreak: true,
})
: slice.content.content
// Use plain text serializer only for code-only content
const usePlainText = isSingleCodeBlock || hasOnlyCodeMark;
return usePlainText
? slice.content.content
.map((node) => ProsemirrorHelper.toPlainText(node))
.join("");
.join("")
: mdSerializer.serialize(slice.content, {
softBreak: true,
});
},
},
}),
+3 -2
View File
@@ -1,4 +1,5 @@
import { action, observable } from "mobx";
import { v4 as uuidv4 } from "uuid";
import { toggleMark } from "prosemirror-commands";
import { Node, Slice } from "prosemirror-model";
import {
@@ -143,7 +144,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Document,
modelId: document.id,
label: document.titleWithDefault,
id: crypto.randomUUID(),
id: uuidv4(),
})
)
);
@@ -188,7 +189,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: crypto.randomUUID(),
id: uuidv4(),
})
)
);
+116
View File
@@ -0,0 +1,116 @@
import some from "lodash/some";
import { action, observable } from "mobx";
import {
EditorState,
NodeSelection,
Selection,
Plugin,
TextSelection,
} from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { SelectionToolbar } from "../components/SelectionToolbar";
export default class SelectionToolbarExtension extends Extension {
get name() {
return "selection-toolbar";
}
get allowInReadOnly() {
return true;
}
get plugins(): Plugin[] {
return [
new Plugin({
view: () => ({
update: this.handleUpdate,
}),
}),
];
}
@observable
state: Selection | boolean = false;
private handleUpdate = action((view: EditorView) => {
const { state } = view;
this.state = this.calculateState(state);
});
private calculateState(state: EditorState): Selection | boolean {
const { selection, doc, schema } = state;
if (isMarkActive(schema.marks.link)(state)) {
return selection;
}
if (
(isNodeActive(schema.nodes.code_block)(state) ||
isNodeActive(schema.nodes.code_fence)(state)) &&
selection.from > 0
) {
return selection;
}
if (isInNotice(state) && selection.from > 0) {
return selection;
}
if (!selection || selection.empty) {
return false;
}
if (
selection instanceof NodeSelection &&
selection.node.type.name === "hr"
) {
return selection;
}
if (
selection instanceof NodeSelection &&
["image", "attachment", "embed"].includes(selection.node.type.name)
) {
return selection;
}
if (selection instanceof NodeSelection) {
return false;
}
const selectionText = doc.cut(selection.from, selection.to).textContent;
if (selection instanceof TextSelection && !selectionText) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
if (some(nodes, (n) => n.content.size)) {
return selection;
}
return false;
}
widget = (props: WidgetProps) => {
const editorProps = this.editor.props;
return (
<SelectionToolbar
{...props}
isActive={!!this.state}
selection={this.state ? (this.state as Selection) : undefined}
canUpdate={editorProps.canUpdate}
canComment={editorProps.canComment}
isTemplate={editorProps.template === true}
onClickLink={editorProps.onClickLink}
/>
);
};
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
// Note that the suppression of pipe here prevents conflict with table creation rule.
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
const emdash = new InputRule(/(?:^|[^\|])(--\s)$/, "— ");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
+2
View File
@@ -10,6 +10,7 @@ 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 SelectionToolbarExtension from "~/editor/extensions/SelectionToolbar";
import SmartText from "~/editor/extensions/SmartText";
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
@@ -24,6 +25,7 @@ export const withUIExtensions = (nodes: Nodes) => [
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
SelectionToolbarExtension,
// Order these default key handlers last
PreventTab,
Keys,
+56 -51
View File
@@ -52,15 +52,16 @@ import Logger from "~/utils/Logger";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
import isNull from "lodash/isNull";
import { map } from "lodash";
import { isArray, map } from "lodash";
import {
LightboxImage,
LightboxImageFactory,
} from "@shared/editor/lib/Lightbox";
import Lightbox from "~/components/Lightbox";
import { anchorPlugin } from "@shared/editor/plugins/anchorPlugin";
export type Props = {
/** An optional identifier for the editor context. It is used to persist local settings */
@@ -150,8 +151,6 @@ type State = {
isRTL: boolean;
/** If the editor is currently focused */
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** Image that's being currently viewed in Lightbox */
activeLightboxImage: LightboxImage | null;
};
@@ -182,7 +181,6 @@ export class Editor extends React.PureComponent<
state: State = {
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
activeLightboxImage: null,
};
@@ -270,19 +268,12 @@ export class Editor extends React.PureComponent<
this.calculateDir();
}
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
!this.state.selectionToolbarOpen
) {
if (!this.isBlurred && !this.state.isEditorFocused) {
this.isBlurred = true;
this.props.onBlur?.();
}
if (
this.isBlurred &&
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
) {
if (this.isBlurred && this.state.isEditorFocused) {
this.isBlurred = false;
this.props.onFocus?.();
}
@@ -416,6 +407,7 @@ export class Editor extends React.PureComponent<
plugins: [
...this.keymaps,
...this.plugins,
anchorPlugin(),
dropCursor({
color: this.props.theme.cursor,
}),
@@ -678,19 +670,36 @@ export class Editor extends React.PureComponent<
public removeComment = (commentId: string) => {
const { state, dispatch } = this.view;
const tr = state.tr;
let markRemoved = false;
state.doc.descendants((node, pos) => {
if (!node.isInline) {
return;
if (markRemoved) {
return false;
}
const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
if (mark) {
tr.removeMark(pos, pos + node.nodeSize, mark);
markRemoved = true;
return;
}
if (isArray(node.attrs?.marks)) {
const existingMarks = node.attrs.marks;
const updatedMarks = existingMarks.filter(
(mark: any) => mark.attrs.id !== commentId
);
const attrs = {
...node.attrs,
marks: updatedMarks,
};
tr.setNodeMarkup(pos, undefined, attrs);
markRemoved = true;
}
return;
});
dispatch(tr);
@@ -699,7 +708,7 @@ export class Editor extends React.PureComponent<
/**
* Update all marks related to a specific comment in the document.
*
* @param commentId The id of the comment to remove
* @param commentId The id of the comment to update
* @param attrs The attributes to update
*/
public updateComment = (
@@ -708,10 +717,11 @@ export class Editor extends React.PureComponent<
) => {
const { state, dispatch } = this.view;
const tr = state.tr;
let markUpdated = false;
state.doc.descendants((node, pos) => {
if (!node.isInline) {
return;
if (markUpdated) {
return false;
}
const mark = node.marks.find(
@@ -725,9 +735,27 @@ export class Editor extends React.PureComponent<
...mark.attrs,
...attrs,
});
tr.removeMark(from, to, mark).addMark(from, to, newMark);
markUpdated = true;
return;
}
if (isArray(node.attrs?.marks)) {
const existingMarks = node.attrs.marks;
const updatedMarks = existingMarks.map((mark: any) =>
mark.type === "comment" && mark.attrs.id === commentId
? { ...mark, attrs: { ...mark.attrs, ...attrs } }
: mark
);
const newAttrs = {
...node.attrs,
marks: updatedMarks,
};
tr.setNodeMarkup(pos, undefined, newAttrs);
markUpdated = true;
}
return;
});
dispatch(tr);
@@ -791,23 +819,6 @@ export class Editor extends React.PureComponent<
return false;
};
private handleOpenSelectionToolbar = () => {
this.setState((state) => ({
...state,
selectionToolbarOpen: true,
}));
};
private handleCloseSelectionToolbar = () => {
if (!this.state.selectionToolbarOpen) {
return;
}
this.setState((state) => ({
...state,
selectionToolbarOpen: false,
}));
};
public render() {
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
this.props;
@@ -837,18 +848,7 @@ export class Editor extends React.PureComponent<
ref={this.elementRef}
lang=""
/>
{this.view && (
<SelectionToolbar
rtl={isRTL}
readOnly={readOnly}
canUpdate={this.props.canUpdate}
canComment={this.props.canComment}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onClickLink={this.props.onClickLink}
/>
)}
{this.widgets &&
Object.values(this.widgets).map((Widget, index) => (
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
@@ -880,10 +880,15 @@ const EditorContainer = styled(Styles)<{
${(props) =>
props.focusedCommentId &&
css`
#comment-${props.focusedCommentId} {
span#comment-${props.focusedCommentId} {
background: ${transparentize(0.5, props.theme.brand.marine)};
border-bottom: 2px solid ${props.theme.commentMarkBackground};
}
a#comment-${props.focusedCommentId}
~ span.component-image
div.image-wrapper {
outline: ${props.theme.commentMarkBackground} solid 2px;
}
`}
${(props) =>
+4
View File
@@ -5,8 +5,12 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function attachmentMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
return [
{
name: "replaceAttachment",
+4
View File
@@ -6,8 +6,12 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function dividerMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
return [
+25 -1
View File
@@ -6,16 +6,22 @@ import {
AlignImageRightIcon,
AlignImageCenterIcon,
AlignFullWidthIcon,
CommentIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
export default function imageMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
const isLeftAligned = isNodeActive(schema.nodes.image, {
layoutClass: "left-50",
@@ -75,14 +81,32 @@ export default function imageMenuItems(
visible: !!fetch,
},
{
name: "replaceImage",
tooltip: dictionary.replaceImage,
icon: <ReplaceIcon />,
children: [
{
name: "replaceImage",
label: dictionary.uploadImage,
},
{
name: "editImageUrl",
label: dictionary.editImageUrl,
},
],
},
{
name: "deleteImage",
tooltip: dictionary.deleteImage,
icon: <TrashIcon />,
},
{
name: "separator",
},
{
name: "commentOnImage",
tooltip: dictionary.comment,
shortcut: `${metaDisplay}+⌥+M`,
icon: <CommentIcon />,
},
];
}
+4
View File
@@ -6,8 +6,12 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function tableMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
const isFullWidth = isNodeActive(schema.nodes.table, {
layout: TableLayout.fullWidth,
+11 -3
View File
@@ -25,10 +25,18 @@ import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
export default function tableColMenuItems(
state: EditorState,
index: number,
rtl: boolean,
dictionary: Dictionary
readOnly: boolean,
dictionary: Dictionary,
options: {
index: number;
rtl: boolean;
}
): MenuItem[] {
if (readOnly) {
return [];
}
const { index, rtl } = options;
const { schema, selection } = state;
if (!(selection instanceof CellSelection)) {
+10 -2
View File
@@ -19,9 +19,17 @@ import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
export default function tableRowMenuItems(
state: EditorState,
index: number,
dictionary: Dictionary
readOnly: boolean,
dictionary: Dictionary,
options: {
index: number;
}
): MenuItem[] {
if (readOnly) {
return [];
}
const { index } = options;
const { selection } = state;
if (!(selection instanceof CellSelection)) {
+2
View File
@@ -32,6 +32,7 @@ export default function useDictionary() {
comment: t("Comment"),
copy: t("Copy"),
createLink: t("Create link"),
editImageUrl: t("Edit image URL"),
createLinkError: t("Sorry, an error occurred creating the link"),
createNewDoc: t("Create a new doc"),
createNewChildDoc: t("Create a new child doc"),
@@ -108,6 +109,7 @@ export default function useDictionary() {
untitled: t("Untitled"),
none: t("None"),
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
}),
[t]
);
+29
View File
@@ -0,0 +1,29 @@
import { useState, useMemo } from "react";
import useEventListener from "./useEventListener";
/**
* Mouse position as a tuple of [x, y]
*/
type MousePosition = [number, number];
/**
* Hook to get the current mouse position
*
* @returns Mouse position as a tuple of [x, y]
*/
export const useMousePosition = () => {
const [mousePosition, setMousePosition] = useState<MousePosition>([0, 0]);
const updateMousePosition = useMemo(
() => (ev: MouseEvent) => {
setMousePosition([ev.clientX, ev.clientY]);
},
[]
);
useEventListener("mousemove", updateMousePosition, undefined, {
passive: true,
});
return mousePosition;
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from "react";
import { PaginationParams } from "~/types";
import useRequest from "./useRequest";
type RequestResponse<T> = {
export type RequestResponse<T> = {
/** The return value of the paginated request function. */
data: T[] | undefined;
/** The request error, if any. */
+2 -2
View File
@@ -1,5 +1,5 @@
import invariant from "invariant";
import { action, computed, observable, runInAction } from "mobx";
import { action, comparer, computed, observable, runInAction } from "mobx";
import {
CollectionPermission,
FileOperationFormat,
@@ -156,7 +156,7 @@ export default class Collection extends ParanoidModel {
return this.sort.field === "index";
}
@computed
@computed({ equals: comparer.structural })
get sortedDocuments(): NavigationNode[] | undefined {
if (!this.documents) {
return undefined;
+7 -2
View File
@@ -2,7 +2,7 @@ import { addDays, differenceInDays } from "date-fns";
import i18n, { t } from "i18next";
import capitalize from "lodash/capitalize";
import floor from "lodash/floor";
import { action, autorun, computed, observable, set } from "mobx";
import { action, autorun, comparer, computed, observable, set } from "mobx";
import type {
JSONObject,
NavigationNode,
@@ -89,6 +89,11 @@ export default class Document extends ArchivableModel implements Searchable {
return this.title;
}
@computed
get searchSuppressed(): boolean {
return this.isDeleted || this.isArchived;
}
/**
* The name of the original data source, if imported.
*/
@@ -647,7 +652,7 @@ export default class Document extends ArchivableModel implements Searchable {
);
}
@computed
@computed({ equals: comparer.structural })
get asNavigationNode(): NavigationNode {
return {
type: NavigationNodeType.Document,
+16 -1
View File
@@ -3,8 +3,9 @@ import GroupMembership from "./GroupMembership";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { GroupPermission } from "@shared/types";
import { Searchable } from "./interfaces/Searchable";
class Group extends Model {
class Group extends Model implements Searchable {
static modelName = "Group";
@Field
@@ -17,6 +18,10 @@ class Group extends Model {
@observable
memberCount: number;
@Field
@observable
disableMentions: boolean;
/**
* Returns the users that are members of this group.
*/
@@ -26,6 +31,16 @@ class Group extends Model {
return users.inGroup(this.id);
}
@computed
get searchContent(): string[] {
return [this.name].filter(Boolean);
}
@computed
get searchSuppressed(): boolean {
return this.disableMentions;
}
@computed
get admins() {
const { groupUsers } = this.store.rootStore;
+5
View File
@@ -122,6 +122,9 @@ class Notification extends Model {
case NotificationEventType.MentionedInDocument:
case NotificationEventType.MentionedInComment:
return t("mentioned you in");
case NotificationEventType.GroupMentionedInComment:
case NotificationEventType.GroupMentionedInDocument:
return t("mentioned your group in");
case NotificationEventType.CreateComment:
return t("left a comment on");
case NotificationEventType.ResolveComment:
@@ -177,9 +180,11 @@ class Notification extends Model {
return collection ? collectionPath(collection.path) : "";
}
case NotificationEventType.AddUserToDocument:
case NotificationEventType.GroupMentionedInDocument:
case NotificationEventType.MentionedInDocument: {
return this.document?.path;
}
case NotificationEventType.GroupMentionedInComment:
case NotificationEventType.MentionedInComment:
case NotificationEventType.ResolveComment:
case NotificationEventType.CreateComment:
+5
View File
@@ -105,6 +105,11 @@ class Share extends Model implements Searchable {
return [this.title];
}
@computed
get searchSuppressed(): boolean {
return false;
}
@computed
get sharedCache() {
return (
+5
View File
@@ -68,6 +68,11 @@ class User extends ParanoidModel implements Searchable {
return [this.name, this.email].filter(Boolean);
}
@computed
get searchSuppressed(): boolean {
return this.isDeleted;
}
@computed
get initial(): string {
return (this.name ? this.name[0] : "?").toUpperCase();
+6 -1
View File
@@ -1,11 +1,12 @@
import pick from "lodash/pick";
import { observable, action } from "mobx";
import { observable, action, toJS } from "mobx";
import { JSONObject } from "@shared/types";
import type Store from "~/stores/base/Store";
import Logger from "~/utils/Logger";
import { getFieldsForModel } from "../decorators/Field";
import { LifecycleManager } from "../decorators/Lifecycle";
import { getRelationsForModelClass } from "../decorators/Relation";
import { isEqual } from "lodash";
export default abstract class Model {
static modelName: string;
@@ -147,6 +148,10 @@ export default abstract class Model {
continue;
}
// @ts-expect-error TODO
if (isEqual(toJS(this[key]), data[key])) {
continue;
}
// @ts-expect-error TODO
this[key] = data[key];
} catch (error) {
Logger.warn(`Error setting ${key} on model`, error);
+2
View File
@@ -4,4 +4,6 @@
export interface Searchable {
/** The content to be used for search */
get searchContent(): string | string[];
get searchSuppressed(): boolean;
}
+17 -14
View File
@@ -13,6 +13,7 @@ import Document from "~/models/Document";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import Text from "~/components/Text";
import { MeasuredContainer } from "~/components/MeasuredContainer";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
@@ -81,20 +82,22 @@ function Overview({ collection, shareId }: Props) {
{collections.isSaving && <LoadingIndicator />}
{(collection.hasDescription || can.update) && (
<Suspense fallback={<Placeholder>Loading</Placeholder>}>
<Editor
defaultValue={collection.data}
onChange={handleSave}
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update || !!shareId}
userId={user?.id}
editorStyle={editorStyle}
shareId={shareId}
/>
<div ref={childRef} />
<MeasuredContainer name="document">
<Editor
defaultValue={collection.data}
onChange={handleSave}
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update || !!shareId}
userId={user?.id}
editorStyle={editorStyle}
shareId={shareId}
/>
<div ref={childRef} />
</MeasuredContainer>
</Suspense>
)}
</>
@@ -1,4 +1,5 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { v4 as uuidv4 } from "uuid";
import { m } from "framer-motion";
import { action } from "mobx";
import { observer } from "mobx-react";
@@ -160,7 +161,7 @@ function CommentForm({
comments
);
comment.id = crypto.randomUUID();
comment.id = uuidv4();
comments.add(comment);
comment
+14 -2
View File
@@ -41,6 +41,7 @@ import PlaceholderDocument from "~/components/PlaceholderDocument";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import withStores from "~/components/withStores";
import { MeasuredContainer } from "~/components/MeasuredContainer";
import type { Editor as TEditor } from "~/editor";
import { Properties } from "~/types";
import { client } from "~/utils/ApiClient";
@@ -54,7 +55,6 @@ import Container from "./Container";
import Contents from "./Contents";
import Editor from "./Editor";
import Header from "./Header";
import { MeasuredContainer } from "./MeasuredContainer";
import Notices from "./Notices";
import PublicReferences from "./PublicReferences";
import References from "./References";
@@ -417,6 +417,18 @@ class DocumentScene extends React.Component<Props> {
void this.onSave();
});
handleSelectTemplate = async (template: Document | Revision) => {
const doc = this.editor.current?.view.state.doc;
if (!doc) {
return;
}
return this.replaceSelection(
template,
ProsemirrorHelper.isEmpty(doc) ? new AllSelection(doc) : undefined
);
};
goBack = () => {
if (!this.props.readOnly) {
this.props.history.push({
@@ -533,7 +545,7 @@ class DocumentScene extends React.Component<Props> {
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceSelection}
onSelectTemplate={this.handleSelectTemplate}
onSave={this.onSave}
/>
<Main
+4 -1
View File
@@ -56,6 +56,7 @@ function Login({ children, onBack }: Props) {
const location = useLocation();
const query = useQuery();
const notice = query.get("notice");
const forceOTP = query.get("forceOTP");
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
@@ -206,7 +207,7 @@ function Login({ children, onBack }: Props) {
(provider) => provider.id === auth.lastSignedIn && !isCreate
);
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
const preferOTP = isPWA;
const preferOTP = isPWA || !!forceOTP;
if (firstRun) {
return (
@@ -324,6 +325,7 @@ function Login({ children, onBack }: Props) {
<AuthenticationProvider
isCreate={isCreate}
onEmailSuccess={handleEmailSuccess}
preferOTP={preferOTP}
{...defaultProvider}
/>
{hasMultipleProviders && (
@@ -348,6 +350,7 @@ function Login({ children, onBack }: Props) {
key={provider.id}
isCreate={isCreate}
onEmailSuccess={handleEmailSuccess}
preferOTP={preferOTP}
neutral={defaultProvider && hasMultipleProviders}
{...provider}
/>
@@ -3,7 +3,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { Client } from "@shared/types";
import { isPWA } from "@shared/utils/browser";
import ButtonLarge from "~/components/ButtonLarge";
import InputLarge from "~/components/InputLarge";
import PluginIcon from "~/components/PluginIcon";
@@ -17,6 +16,7 @@ type Props = React.ComponentProps<typeof ButtonLarge> & {
authUrl: string;
isCreate: boolean;
onEmailSuccess: (email: string) => void;
preferOTP: boolean;
};
type AuthState = "initial" | "email" | "code";
@@ -28,7 +28,6 @@ function AuthenticationProvider(props: Props) {
const [email, setEmail] = React.useState("");
const { isCreate, id, name, authUrl, onEmailSuccess, ...rest } = props;
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
const preferOTP = isPWA;
const handleChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.target.value);
@@ -46,7 +45,7 @@ function AuthenticationProvider(props: Props) {
const response = await client.post(event.currentTarget.action, {
email,
client: clientType,
preferOTP,
preferOTP: props.preferOTP,
});
if (response.redirect) {
+2 -1
View File
@@ -1,4 +1,5 @@
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import queryString from "query-string";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -104,7 +105,7 @@ function Search() {
// without a flash of loading.
if (query) {
searches.add({
id: crypto.randomUUID(),
id: uuidv4(),
query,
createdAt: new Date().toISOString(),
});
+9
View File
@@ -13,6 +13,7 @@ import {
SmileyIcon,
StarredIcon,
UserIcon,
GroupIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
@@ -70,6 +71,14 @@ function Notifications() {
"Receive a notification when someone mentions you in a document or comment"
),
},
{
event: NotificationEventType.GroupMentionedInDocument,
icon: <GroupIcon />,
title: t("Group mentions"),
description: t(
"Receive a notification when someone mentions a group you are a member of in a document or comment"
),
},
{
event: NotificationEventType.ResolveComment,
icon: <DoneIcon />,
@@ -29,6 +29,7 @@ import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelec
import { GroupPermission } from "@shared/types";
import { EmptySelectValue, Permission } from "~/types";
import GroupUser from "~/models/GroupUser";
import Switch from "~/components/Switch";
type Props = {
group: Group;
@@ -103,6 +104,9 @@ export function CreateGroupDialog() {
export function EditGroupDialog({ group, onSubmit }: Props) {
const { t } = useTranslation();
const [name, setName] = React.useState(group.name);
const [disableMentions, setDisableMentions] = React.useState(
group.disableMentions || false
);
const [isSaving, setIsSaving] = React.useState(false);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
@@ -112,6 +116,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
try {
await group.save({
name,
disableMentions,
});
onSubmit();
} catch (err) {
@@ -120,7 +125,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
setIsSaving(false);
}
},
[group, onSubmit, name]
[group, onSubmit, name, disableMentions]
);
const handleNameChange = React.useCallback(
@@ -138,7 +143,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
often might confuse your team mates.
</Trans>
</Text>
<Flex>
<Flex column>
<Input
type="text"
label={t("Name")}
@@ -148,6 +153,15 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
autoFocus
flex
/>
<Switch
id="mentions"
label={t("Disable mentions")}
note={t(
"Prevent this group from being mentionable in documents or comments"
)}
checked={disableMentions}
onChange={setDisableMentions}
/>
</Flex>
<Button type="submit" disabled={isSaving || !name}>
+2 -1
View File
@@ -1,4 +1,5 @@
import { observable, action } from "mobx";
import { v4 as uuidv4 } from "uuid";
import * as React from "react";
type DialogDefinition = {
@@ -65,7 +66,7 @@ export default class DialogsStore {
this.modalStack.clear();
}
this.modalStack.set(id ?? replaceId ?? crypto.randomUUID(), {
this.modalStack.set(id ?? replaceId ?? uuidv4(), {
title,
content,
style,
+3
View File
@@ -37,6 +37,9 @@ export default class PinsStore extends Store<Pin> {
documentId,
collectionId,
});
if (!res) {
return;
}
invariant(res?.data, "Data should be available");
return this.add(res.data);
} catch (err) {
+3
View File
@@ -34,6 +34,9 @@ export default class SubscriptionsStore extends Store<Subscription> {
try {
const res = await client.post(`/${this.apiEndpoint}.info`, options);
if (!res) {
return;
}
invariant(res?.data, "Data should be available");
return this.add(res.data);
} catch (err) {
+8
View File
@@ -86,6 +86,9 @@ class UiStore {
@observable
multiplayerErrorCode?: number;
@observable
debugSafeArea = false;
rootStore: RootStore;
constructor(rootStore: RootStore) {
@@ -248,6 +251,11 @@ class UiStore {
this.mobileSidebarVisible = false;
};
@action
toggleDebugSafeArea = () => {
this.debugSafeArea = !this.debugSafeArea;
};
@computed
get readyToShow() {
return (
+2 -13
View File
@@ -101,24 +101,13 @@ export default abstract class Store<T extends Model> {
if (!normalized) {
return this.orderedData
.filter((item) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("archivedAt" in item && item.archivedAt) {
return false;
}
return true;
})
.filter((item: T & Searchable) => !item.searchSuppressed)
.slice(0, options?.maxResults);
}
return this.orderedData
.filter((item: T & Searchable) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("archivedAt" in item && item.archivedAt) {
if (item.searchSuppressed) {
return false;
}
if ("searchContent" in item) {
-1
View File
@@ -150,7 +150,6 @@ declare module "styled-components" {
menuItemSelected: string;
menuBackground: string;
menuShadow: string;
menuBorder?: string;
divider: string;
titleBarDivider: string;
inputBorder: string;
+21 -20
View File
@@ -41,7 +41,7 @@
"url": "https://github.com/sponsors/outline"
},
"engines": {
"node": "20 || 22"
"node": ">=20.12 <21 || 22"
},
"repository": {
"type": "git",
@@ -51,18 +51,18 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.908.0",
"@aws-sdk/lib-storage": "3.908.0",
"@aws-sdk/s3-presigned-post": "3.908.0",
"@aws-sdk/s3-request-presigner": "3.908.0",
"@aws-sdk/signature-v4-crt": "^3.908.0",
"@babel/core": "^7.28.4",
"@aws-sdk/client-s3": "3.917.0",
"@aws-sdk/lib-storage": "3.917.0",
"@aws-sdk/s3-presigned-post": "3.917.0",
"@aws-sdk/s3-request-presigner": "3.917.0",
"@aws-sdk/signature-v4-crt": "^3.916.0",
"@babel/core": "^7.28.5",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0",
"@babel/plugin-transform-destructuring": "^7.28.5",
"@babel/plugin-transform-regenerator": "^7.28.4",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.27.1",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.13.0",
@@ -149,14 +149,14 @@
"i18next-fs-backend": "^2.6.0",
"i18next-http-backend": "^2.7.3",
"invariant": "^2.2.4",
"ioredis": "^5.7.0",
"ioredis": "^5.8.2",
"is-printable-key-event": "^1.0.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
"jszip": "^3.10.1",
"katex": "^0.16.22",
"kbar": "0.1.0-beta.48",
"koa": "^3.0.1",
"koa": "^3.0.3",
"koa-body": "^6.0.1",
"koa-compress": "^5.1.1",
"koa-helmet": "^6.1.0",
@@ -181,13 +181,13 @@
"node-fetch": "2.7.0",
"nodemailer": "^7.0.7",
"octokit": "^3.2.2",
"outline-icons": "^3.13.0",
"outline-icons": "^3.13.1",
"oy-vey": "^0.12.1",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",
"passport-oauth2": "^1.8.0",
"passport-slack-oauth2": "^1.2.0",
"patch-package": "^8.0.0",
"patch-package": "^8.0.1",
"pg": "^8.16.3",
"pg-tsquery": "^8.4.2",
"pluralize": "^8.0.0",
@@ -262,7 +262,8 @@
"ukkonen": "^2.2.0",
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"validator": "13.15.15",
"uuid": "^11.1.0",
"validator": "13.15.20",
"vaul": "^1.1.2",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-pwa": "1.0.3",
@@ -277,7 +278,7 @@
},
"devDependencies": {
"@babel/cli": "^7.28.3",
"@babel/preset-typescript": "^7.27.1",
"@babel/preset-typescript": "^7.28.5",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
"@types/addressparser": "^1.0.3",
@@ -329,7 +330,7 @@
"@types/react-router-dom": "^5.3.3",
"@types/react-virtualized-auto-sizer": "^1.0.8",
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.21",
"@types/readable-stream": "^4.0.22",
"@types/redis-info": "^3.0.3",
"@types/refractor": "^3.4.1",
"@types/resolve-path": "^1.4.3",
@@ -350,7 +351,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.38.20",
"discord-api-types": "^0.38.30",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"ioredis-mock": "^8.9.0",
@@ -363,7 +364,7 @@
"oxlint-tsgolint": "^0.1.6",
"postinstall-postinstall": "^2.1.0",
"prettier": "^3.6.2",
"react-refresh": "^0.17.0",
"react-refresh": "^0.18.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "2.1.6",
"terser": "^5.43.1",
@@ -381,6 +382,6 @@
"qs": "6.9.7",
"prismjs": "1.30.0"
},
"version": "0.87.4",
"version": "1.0.1",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+9 -7
View File
@@ -72,7 +72,7 @@ router.post(
}
// Generate both a link token and a 6-digit verification code
const token = preferOTP ? undefined : user.getEmailSigninToken();
const token = preferOTP ? undefined : user.getEmailSigninToken(ctx);
const verificationCode = preferOTP
? await user.getEmailVerificationCode()
: undefined;
@@ -131,7 +131,7 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
try {
if (token) {
user = await getUserForEmailSigninToken(token as string);
user = await getUserForEmailSigninToken(ctx, token as string);
} else if (code && email) {
user = await User.scope("withTeam").findOne({
rejectOnEmpty: true,
@@ -150,16 +150,18 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
// Delete the code after successful verification
await VerificationCode.delete(email);
} else {
ctx.redirect("/?notice=auth-error");
ctx.redirect("/?notice=auth-error&description=Missing%20token");
return;
}
} catch (err) {
Logger.debug("authentication", err);
return ctx.redirect("/?notice=auth-error");
return ctx.redirect(`/?notice=auth-error&description=${err.message}`);
}
if (!user.team.emailSigninEnabled) {
return ctx.redirect("/?notice=auth-error");
return ctx.redirect(
"/?notice=auth-error&description=Disabled%20signin%20method"
);
}
if (user.isSuspended) {
@@ -195,13 +197,13 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
};
router.get(
"email.callback",
rateLimiter(RateLimiterStrategy.TenPerHour),
rateLimiter(RateLimiterStrategy.FivePerMinute),
validate(T.EmailCallbackSchema),
emailCallback
);
router.post(
"email.callback",
rateLimiter(RateLimiterStrategy.TenPerHour),
rateLimiter(RateLimiterStrategy.FivePerMinute),
validate(T.EmailCallbackSchema),
emailCallback
);
@@ -0,0 +1,76 @@
import { Trans } from 'react-i18next';
export const Translations = () => (
<>
<Trans defaults={`New attribute`} />
<Trans defaults={`Paper size`} />
<Trans defaults={`Ask AI "{{question}}"`} />
<Trans defaults={`Are you sure you want to delete?`} />
<Trans defaults={`Deleting this version of the document will permanently and irrevocably remove it from the history.`} />
<Trans defaults={`Format`} />
<Trans defaults={`Add option`} />
<Trans defaults={`Optional`} />
<Trans defaults={`Choose a size for your exported document`} />
<Trans defaults={`Revision renamed`} />
<Trans defaults={`Failed to save revision`} />
<Trans defaults={`Invite to document`} />
<Trans defaults={`Sorry, invalid embed link`} />
<Trans defaults={`Data Attributes`} />
<Trans defaults={`Edit attribute`} />
<Trans defaults={`Property`} />
<Trans defaults={`Yes`} />
<Trans defaults={`No`} />
<Trans defaults={`Search or ask a question`} />
<Trans defaults={`Invited {{roleName}} will not receive access to any collections or documents unless explicitly shared.`} />
<Trans defaults={`Can view only what is explicitly shared`} />
<Trans defaults={`SAML assertion was invalid or missing fields, please check your configuration`} />
<Trans defaults={`AI generated answer based on related documents in your workspace`} />
<Trans defaults={`References`} />
<Trans defaults={`Enable AI answers to get direct answers to searched questions.`} />
<Trans defaults={`Go to settings`} />
<Trans defaults={`Where do I find the file?`} />
<Trans defaults={`In a Confluence space, navigate to <em>Space Settings -> Manage space -> Export space</em> and choose to export as HTML with the "Normal Export" option.`} />
<Trans defaults={`Drag and drop the zip file from Confluence's HTML export option, or click to upload`} />
<Trans defaults={`Guests`} />
<Trans defaults={`New Attribute`} />
<Trans defaults={`Attributes allow you to define data to be stored with your documents. They can be used to store custom properties, metadata, or any other structured information that is common across documents.`} />
<Trans defaults={`Custom domain`} />
<Trans defaults={`AI answers`} />
<Trans defaults={`Use AI to directly answer searched questions using content in your workspace.`} />
<Trans defaults={`API access`} />
<Trans defaults={`Allow members to create API keys for programmatic access`} />
<Trans defaults={`Public document embedding`} />
<Trans defaults={`When enabled, publicly shared documents can be embedded in third-party websites`} />
<Trans defaults={`Include previews in emails`} />
<Trans defaults={`When enabled, email notifications will include content previews`} />
<Trans defaults={`Boolean`} />
<Trans defaults={`Number`} />
<Trans defaults={`Text`} />
<Trans defaults={`List`} />
<Trans defaults={`Could not load events`} />
<Trans defaults={`Audit Log`} />
<Trans defaults={`The audit log details the history of security related and other events across your knowledge base.`} />
<Trans defaults={`IP address`} />
<Trans defaults={`Actor`} />
<Trans defaults={`Event`} />
<Trans defaults={`Timestamp`} />
<Trans defaults={`IP`} />
<Trans defaults={`a group`} />
<Trans defaults={`All users`} />
<Trans defaults={`Private`} />
<Trans defaults={`View and edit`} />
<Trans defaults={`Sharing enabled`} />
<Trans defaults={`Date archived`} />
<Trans defaults={`Could not load collections`} />
<Trans defaults={`Manage the permissions and settings of all collections in the knowledge base. As a workspace admin you can also administer private collections.`} />
<Trans defaults={`Automatically index and search document content from {{appName}} inside <4>Glean</4> in realtime.`} />
<Trans defaults={`API Endpoint`} />
<Trans defaults={`API Secret`} />
<Trans defaults={`Datasource`} />
<Trans defaults={`Details of the current {{appName}} license. To arrange contract renewal as expiry or seat limits approach or increase licensed seats please contact your account manager or email <4>priority@getoutline.com</4>.`} />
<Trans defaults={`Sorry, an answer could not be found in the collection, try widening your search.`} />
<Trans defaults={`Sorry, an answer could not be found in the workspace, try widening your search.`} />
<Trans defaults={`Looking for answers`} />
<Trans defaults={`Answer to "{{ query }}"`} />
</>
)
+24 -11
View File
@@ -13,6 +13,8 @@ import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
import { LinearUtils } from "../shared/LinearUtils";
import env from "./env";
import { Minute } from "@shared/utils/time";
import { opts } from "@server/utils/i18n";
import { t } from "i18next";
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
@@ -111,18 +113,25 @@ export class Linear {
return;
}
const integration = (await Integration.scope("withAuthentication").findOne({
where: {
service: IntegrationService.Linear,
teamId: actor.teamId,
"settings.linear.workspace.key": resource.workspaceKey,
},
})) as Integration<IntegrationType.Embed>;
const integrations = (await Integration.scope("withAuthentication").findAll(
{
where: {
service: IntegrationService.Linear,
teamId: actor.teamId,
},
}
)) as Integration<IntegrationType.Embed>[];
if (!integration) {
if (integrations.length === 0) {
return;
}
// Prefer integration with matching workspaceKey, otherwise pick the first one
const integration =
integrations.find(
(int) => int.settings.linear?.workspace.key === resource.workspaceKey
) ?? integrations[0];
try {
const accessToken = await integration.authentication.refreshTokenIfNeeded(
async (refreshToken: string) => Linear.refreshToken(refreshToken),
@@ -142,7 +151,7 @@ export class Linear {
issue.paginate(issue.labels, {}),
]);
if (!author || !state || !labels) {
if (!state || !labels) {
return { error: "Failed to fetch auxiliary data from Linear" };
}
@@ -159,8 +168,12 @@ export class Linear {
title: issue.title,
description: issue.description ?? null,
author: {
name: author.name,
avatarUrl: author.avatarUrl ?? "",
name:
author?.name ??
issue.botActor?.userDisplayName ??
issue.botActor?.name ??
t("Unknown", opts(actor)),
avatarUrl: author?.avatarUrl ?? "",
},
labels: labels.map((label) => ({
name: label.name,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+8 -1
View File
@@ -1,5 +1,6 @@
import { Event, Document, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { APIContext } from "@server/types";
type Props = {
@@ -85,7 +86,13 @@ export default async function documentUpdater(
document.insightsEnabled = insightsEnabled;
}
if (text !== undefined) {
document = DocumentHelper.applyMarkdownToDocument(document, text, append);
document = DocumentHelper.applyMarkdownToDocument(
document,
await TextHelper.replaceImagesWithAttachments(ctx, text, user, {
base64Only: true,
}),
append
);
}
const changed = document.changed();
+1 -1
View File
@@ -108,7 +108,7 @@ export default async function userInviter(
"email",
`Sign in immediately: ${
env.URL
}/auth/email.callback?token=${newUser.getEmailSigninToken()}`
}/auth/email.callback?token=${newUser.getEmailSigninToken(ctx)}`
);
}
}
+11 -1
View File
@@ -1,6 +1,7 @@
import addressparser, { EmailAddress } from "addressparser";
import Bull from "bull";
import invariant from "invariant";
import { subMinutes } from "date-fns";
import { Node } from "prosemirror-model";
import { randomString } from "@shared/random";
import { TeamPreference } from "@shared/types";
@@ -143,12 +144,21 @@ export default abstract class BaseEmail<
? await Notification.emailReferences(notification)
: undefined;
// Check if notification is considerably delayed and annotate
// the subject. This is incase of extended downtime or queue backlogs
let subject = this.subject(data);
if (notification) {
if (notification.createdAt < subMinutes(new Date(), 30)) {
subject = `Delayed notification: ${subject}`;
}
}
try {
await mailer.sendMail({
to: this.props.to,
replyTo: this.replyTo?.(data),
from: this.from(data),
subject: this.subject(data),
subject,
messageId,
references,
previewText: this.preview(data),
@@ -0,0 +1,176 @@
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { Collection, Comment, Document, Group } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { can } from "@server/policies";
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = EmailProps & {
groupId: string;
userId: string;
documentId: string;
actorName: string;
commentId: string;
teamUrl: string;
};
type BeforeSend = {
document: Document;
collection: Collection;
body: string | undefined;
unsubscribeUrl: string;
groupName: string;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when they are a member of a group mentioned in a comment.
*/
export default class GroupCommentMentionedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected get category() {
return EmailMessageCategory.Notification;
}
protected async beforeSend(props: InputProps) {
const { documentId, commentId, groupId } = props;
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
const group = await Group.findByPk(groupId);
if (!group) {
return false;
}
const collection = await document.$get("collection");
if (!collection) {
return false;
}
const [comment, team] = await Promise.all([
Comment.findByPk(commentId),
document.$get("team"),
]);
if (!comment || !team) {
return false;
}
const body = await this.htmlForData(
team,
ProsemirrorHelper.toProsemirror(comment.data)
);
return {
document,
collection,
body,
groupName: group.name,
unsubscribeUrl: this.unsubscribeUrl(props),
};
}
protected unsubscribeUrl({ userId }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(
userId,
NotificationEventType.GroupMentionedInComment
);
}
protected replyTo({ notification }: Props) {
if (notification?.user && notification.actor?.email) {
if (can(notification.user, "readEmail", notification.actor)) {
return notification.actor.email;
}
}
return;
}
protected subject({ document, groupName }: Props) {
return `The ${groupName} group was mentioned in “${document.titleWithDefault}`;
}
protected preview({ actorName, groupName }: Props): string {
return `${actorName} mentioned the "${groupName}" group in a thread`;
}
protected fromName({ actorName }: Props): string {
return actorName;
}
protected renderAsText({
actorName,
teamUrl,
document,
commentId,
collection,
groupName,
}: Props): string {
return `
${actorName} mentioned the "${groupName}" group in a comment on "${document.titleWithDefault}"${
collection.name ? ` in the ${collection.name} collection` : ""
}.
Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
`;
}
protected render(props: Props) {
const {
document,
collection,
actorName,
teamUrl,
commentId,
unsubscribeUrl,
body,
groupName,
} = props;
const threadLink = `${teamUrl}${document.url}?commentId=${commentId}&ref=notification-email`;
return (
<EmailTemplate
previewText={this.preview(props)}
goToAction={{ url: threadLink, name: "View Thread" }}
>
<Header />
<Body>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} mentioned the "{groupName}" group in a comment on{" "}
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
{collection.name ? ` in the ${collection.name} collection` : ""}.
</p>
{body && (
<>
<EmptySpace height={20} />
<Diff>
<div dangerouslySetInnerHTML={{ __html: body }} />
</Diff>
<EmptySpace height={20} />
</>
)}
<p>
<Button href={threadLink}>Open Thread</Button>
</p>
</Body>
<Footer unsubscribeUrl={unsubscribeUrl} />
</EmailTemplate>
);
}
}
@@ -0,0 +1,167 @@
import differenceBy from "lodash/differenceBy";
import * as React from "react";
import { MentionType } from "@shared/types";
import { Document, Revision, Group } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { can } from "@server/policies";
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = EmailProps & {
documentId: string;
revisionId: string | undefined;
actorName: string;
teamUrl: string;
groupId: string;
};
type BeforeSend = {
document: Document;
groupName: string;
body: string | undefined;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when they are a member of a group mentioned in a document.
*/
export default class GroupDocumentMentionedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected get category() {
return EmailMessageCategory.Notification;
}
protected async beforeSend({ documentId, revisionId, groupId }: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
const group = await Group.findByPk(groupId);
if (!group) {
return false;
}
const team = await document.$get("team");
if (!team) {
return false;
}
let currDoc: Document | Revision = document;
let prevDoc: Revision | undefined;
if (revisionId) {
const revision = await Revision.findByPk(revisionId);
if (!revision) {
return false;
}
currDoc = revision;
prevDoc = (await revision.before()) ?? undefined;
}
const currMentions = DocumentHelper.parseMentions(currDoc, {
type: MentionType.Group,
modelId: groupId,
});
const prevMentions = prevDoc
? DocumentHelper.parseMentions(prevDoc, {
type: MentionType.Group,
modelId: groupId,
})
: [];
const firstNewMention = differenceBy(currMentions, prevMentions, "id")[0];
let body: string | undefined;
if (firstNewMention) {
const node = ProsemirrorHelper.getNodeForMentionEmail(
DocumentHelper.toProsemirror(currDoc),
firstNewMention
);
if (node) {
body = await this.htmlForData(team, node);
}
}
return { document, body, groupName: group.name };
}
protected subject({ document, groupName }: Props) {
return `The ${groupName} group was mentioned in “${document.titleWithDefault}`;
}
protected preview({ actorName, groupName }: Props): string {
return `${actorName} mentioned the "${groupName}" group`;
}
protected fromName({ actorName }: Props) {
return actorName;
}
protected replyTo({ notification }: Props) {
if (notification?.user && notification.actor?.email) {
if (can(notification.user, "readEmail", notification.actor)) {
return notification.actor.email;
}
}
return;
}
protected renderAsText({
actorName,
teamUrl,
document,
groupName,
}: Props): string {
return `
${actorName} mentioned the “${groupName}” group in the document “${document.titleWithDefault}”.
Open Document: ${teamUrl}${document.url}
`;
}
protected render(props: Props) {
const { document, actorName, teamUrl, body, groupName } = props;
const documentLink = `${teamUrl}${document.url}?ref=notification-email`;
return (
<EmailTemplate
previewText={this.preview(props)}
goToAction={{ url: documentLink, name: "View Document" }}
>
<Header />
<Body>
<Heading>Your group was mentioned</Heading>
<p>
{actorName} mentioned the "{groupName}" group in the document{" "}
<a href={documentLink}>{document.titleWithDefault}</a>.
</p>
{body && (
<>
<EmptySpace height={20} />
<Diff>
<div dangerouslySetInnerHTML={{ __html: body }} />
</Diff>
<EmptySpace height={20} />
</>
)}
<p>
<Button href={documentLink}>Open Document</Button>
</p>
</Body>
</EmailTemplate>
);
}
}
+10 -2
View File
@@ -6,7 +6,7 @@ import "./logging/tracer"; // must come before importing any instrumented module
import http from "http";
import https from "https";
import Koa from "koa";
import Koa, { Context } from "koa";
import helmet from "koa-helmet";
import logger from "koa-logger";
import Router from "koa-router";
@@ -90,6 +90,7 @@ async function start(_id: number, disconnect: () => void) {
/** Perform a redirect on the browser so that the user's auth cookies are included in the request. */
app.context.redirectOnClient = function (
this: Context,
/** The URL to redirect to */
url: string,
/**
@@ -113,6 +114,13 @@ async function start(_id: number, disconnect: () => void) {
)}" value="${escape(value)}" />`;
});
if (this.userAgent.isBot) {
formFields += `
<p>If you are not redirected automatically, please click the button below.</p>
<input type="submit" value="Continue" />
`;
}
this.body = `
<html>
<head>
@@ -123,7 +131,7 @@ async function start(_id: number, disconnect: () => void) {
${formFields}
</form>
<script nonce="${this.state.cspNonce}">
document.getElementById('redirect-form').submit();
${!this.userAgent.isBot} && document.getElementById('redirect-form').submit();
</script>
</body>
</html>`;
@@ -0,0 +1,19 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("notifications", "groupId", {
type: Sequelize.UUID,
allowNull: true,
onDelete: "cascade",
references: {
model: "groups",
},
});
},
async down(queryInterface) {
await queryInterface.removeColumn("notifications", "groupId");
},
};
@@ -0,0 +1,16 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("groups", "disableMentions", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("groups", "disableMentions");
},
};
+3
View File
@@ -68,6 +68,9 @@ class Group extends ParanoidModel<
@Column
externalId: string;
@Column(DataType.BOOLEAN)
disableMentions: boolean;
static filterByMember(userId: string | undefined) {
return userId
? this.scope({ method: ["withMembership", userId] })
+30
View File
@@ -81,6 +81,36 @@ describe("Notification", () => {
expect(references?.length).toBe(1);
expect(references![0]).toBe(expectedReference);
});
it("group mentioned in document", async () => {
const document = await buildDocument();
const notification = await buildNotification({
event: NotificationEventType.GroupMentionedInDocument,
documentId: document.id,
});
const references = await Notification.emailReferences(notification);
const expectedReference = Notification.emailMessageId(
`${document.id}-group-mentions`
);
expect(references?.length).toBe(1);
expect(references![0]).toBe(expectedReference);
});
it("group mentioned in comment", async () => {
const document = await buildDocument();
const notification = await buildNotification({
event: NotificationEventType.GroupMentionedInComment,
documentId: document.id,
});
const references = await Notification.emailReferences(notification);
const expectedReference = Notification.emailMessageId(
`${document.id}-group-mentions`
);
expect(references?.length).toBe(1);
expect(references![0]).toBe(expectedReference);
});
});
describe("should return comment reference", () => {
+13
View File
@@ -30,6 +30,7 @@ import Event from "./Event";
import Revision from "./Revision";
import Team from "./Team";
import User from "./User";
import Group from "./Group";
import Fix from "./decorators/Fix";
let baseDomain;
@@ -128,6 +129,13 @@ class Notification extends Model<
event: NotificationEventType;
// associations
@BelongsTo(() => Group, "groupId")
group: Group;
@AllowNull
@ForeignKey(() => User)
@Column(DataType.UUID)
groupId: string;
@BelongsTo(() => User, "userId")
user: User;
@@ -202,6 +210,7 @@ class Notification extends Model<
collectionId: model.collectionId,
actorId: model.actorId,
membershipId: model.membershipId,
groupId: model.groupId,
};
if (options.transaction) {
@@ -260,6 +269,10 @@ class Notification extends Model<
case NotificationEventType.UpdateDocument:
name = `${notification.documentId}-updates`;
break;
case NotificationEventType.GroupMentionedInComment:
case NotificationEventType.GroupMentionedInDocument:
name = `${notification.documentId}-group-mentions`;
break;
case NotificationEventType.MentionedInDocument:
case NotificationEventType.MentionedInComment:
name = `${notification.documentId}-mentions`;
+2 -1
View File
@@ -588,10 +588,11 @@ class User extends ParanoidModel<
*
* @returns The email signin token
*/
getEmailSigninToken = () =>
getEmailSigninToken = (ctx: Context) =>
JWT.sign(
{
id: this.id,
ip: ctx.request.ip,
createdAt: new Date().toISOString(),
type: "email-signin",
},
+13 -1
View File
@@ -9,6 +9,7 @@ import FileStorage from "@server/storage/files";
import { APIContext } from "@server/types";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import parseImages from "@server/utils/parseImages";
import { isInternalUrl } from "@shared/utils/urls";
@trace()
export class TextHelper {
@@ -65,7 +66,11 @@ export class TextHelper {
static async replaceImagesWithAttachments(
ctx: APIContext,
markdown: string,
user: User
user: User,
options: {
/** If true, only process base64 encoded images */
base64Only?: boolean;
} = {}
) {
let output = markdown;
const images = parseImages(markdown);
@@ -84,6 +89,13 @@ export class TextHelper {
return;
}
if (isInternalUrl(image.src)) {
return;
}
if (options.base64Only && !image.src.startsWith("data:")) {
return;
}
const attachment = await attachmentCreator({
name: image.alt ?? "image",
url: image.src,
+1
View File
@@ -6,6 +6,7 @@ export default async function presentGroup(group: Group) {
name: group.name,
externalId: group.externalId,
memberCount: await group.memberCount,
disableMentions: group.disableMentions,
createdAt: group.createdAt,
updatedAt: group.updatedAt,
};
+22 -1
View File
@@ -2,7 +2,7 @@ import { differenceInMinutes, formatDistanceToNowStrict } from "date-fns";
import { t } from "i18next";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { dateLocale } from "@shared/utils/date";
import { Document, User, View } from "@server/models";
import { Document, User, View, Group } from "@server/models";
import { opts } from "@server/utils/i18n";
async function presentUnfurl(
@@ -12,6 +12,8 @@ async function presentUnfurl(
switch (data.type) {
case UnfurlResourceType.Mention:
return presentMention(data, options);
case UnfurlResourceType.Group:
return presentGroup(data);
case UnfurlResourceType.Document:
return presentDocument(data);
case UnfurlResourceType.PR:
@@ -54,6 +56,25 @@ const presentMention = async (
};
};
const presentGroup = async (
data: Record<string, any>
): Promise<UnfurlResponse[UnfurlResourceType.Group]> => {
const group: Group = data.group;
const memberCount = await group.memberCount;
return {
type: UnfurlResourceType.Group,
name: group.name,
memberCount,
users: (data.users as User[]).map((user) => ({
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
color: user.color,
})),
};
};
const presentDocument = (
data: Record<string, any>
): UnfurlResponse[UnfurlResourceType.Document] => {
@@ -11,6 +11,8 @@ import DocumentSharedEmail from "@server/emails/templates/DocumentSharedEmail";
import { Notification } from "@server/models";
import { Event, NotificationEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
import GroupDocumentMentionedEmail from "@server/emails/templates/GroupDocumentMentionedEmail";
import GroupCommentMentionedEmail from "@server/emails/templates/GroupCommentMentionedEmail";
export default class EmailsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["notifications.create"];
@@ -83,6 +85,21 @@ export default class EmailsProcessor extends BaseProcessor {
return;
}
case NotificationEventType.GroupMentionedInDocument: {
await new GroupDocumentMentionedEmail(
{
to: notification.user.email,
documentId: notification.documentId,
revisionId: notification.revisionId,
groupId: notification.groupId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
{ notificationId }
).schedule();
return;
}
case NotificationEventType.MentionedInDocument: {
// No need to delay email here as the notification itself is already delayed
await new DocumentMentionedEmail(
@@ -99,6 +116,24 @@ export default class EmailsProcessor extends BaseProcessor {
return;
}
case NotificationEventType.GroupMentionedInComment: {
await new GroupCommentMentionedEmail(
{
to: notification.user.email,
userId: notification.userId,
documentId: notification.documentId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
commentId: notification.commentId,
groupId: notification.groupId,
},
{ notificationId }
).schedule({
delay: Minute.ms,
});
return;
}
case NotificationEventType.MentionedInComment: {
await new CommentMentionedEmail(
{
@@ -5,7 +5,14 @@ import {
} from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import { Comment, Document, Notification, User } from "@server/models";
import {
Comment,
Document,
Group,
GroupUser,
Notification,
User,
} from "@server/models";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { sequelize } from "@server/storage/database";
@@ -77,6 +84,65 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
}
}
// send notifications to users in mentioned groups
const groupMentions = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(comment.data),
{
type: MentionType.Group,
}
);
const mentionedGroup: string[] = [];
for (const group of groupMentions) {
if (mentionedGroup.includes(group.modelId)) {
continue;
}
// Check if the group has mentions disabled
const groupModel = await Group.findByPk(group.modelId);
if (groupModel?.disableMentions) {
continue;
}
const usersFromMentionedGroup = await GroupUser.findAll({
where: {
groupId: group.modelId,
},
order: [["permission", "ASC"]],
});
const mentionedUser: string[] = [];
for (const user of usersFromMentionedGroup) {
if (mentionedUser.includes(user.userId)) {
continue;
}
const recipient = await User.findByPk(user.userId);
if (
recipient &&
recipient.id !== group.actorId &&
recipient.subscribedToEventType(
NotificationEventType.GroupMentionedInComment
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.GroupMentionedInComment,
groupId: group.modelId,
userId: recipient.id,
actorId: group.actorId,
teamId: document.teamId,
documentId: document.id,
commentId: comment.id,
});
mentionedUser.push(user.userId);
}
}
mentionedGroup.push(group.modelId);
}
const recipients = (
await NotificationHelper.getCommentNotificationRecipients(
document,
@@ -1,7 +1,14 @@
import invariant from "invariant";
import { Op } from "sequelize";
import { MentionType, NotificationEventType } from "@shared/types";
import { Comment, Document, Notification, User } from "@server/models";
import {
Comment,
Document,
Group,
GroupUser,
Notification,
User,
} from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { CommentEvent, CommentUpdateEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/permissions";
@@ -69,6 +76,62 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
userIdsMentioned.push(mention.modelId);
}
}
const groupMentions = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(comment.data),
{ type: MentionType.Group }
).filter((mention) => newMentionIds.includes(mention.id));
const mentionedGroup: string[] = [];
for (const group of groupMentions) {
if (mentionedGroup.includes(group.modelId)) {
continue;
}
// Check if the group has mentions disabled
const groupModel = await Group.findByPk(group.modelId);
if (groupModel?.disableMentions) {
continue;
}
const usersFromMentionedGroup = await GroupUser.findAll({
where: {
groupId: group.modelId,
},
order: [["permission", "ASC"]],
});
const mentionedUser: string[] = [];
for (const user of usersFromMentionedGroup) {
if (mentionedUser.includes(user.userId)) {
continue;
}
const recipient = await User.findByPk(user.userId);
if (
recipient &&
recipient.id !== group.actorId &&
recipient.subscribedToEventType(
NotificationEventType.GroupMentionedInComment
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.GroupMentionedInComment,
groupId: group.modelId,
userId: recipient.id,
actorId: group.actorId,
teamId: document.teamId,
documentId: document.id,
commentId: comment.id,
});
mentionedUser.push(user.userId);
}
}
mentionedGroup.push(group.modelId);
}
}
private async handleResolvedComment(event: CommentUpdateEvent) {
@@ -1,6 +1,6 @@
import { MentionType, NotificationEventType } from "@shared/types";
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
import { Document, Notification, User } from "@server/models";
import { Document, Group, Notification, User, GroupUser } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { DocumentEvent } from "@server/types";
@@ -50,6 +50,60 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
}
}
// send notifications to users in mentioned groups
const groupMentions = DocumentHelper.parseMentions(document, {
type: MentionType.Group,
});
const mentionedGroup: string[] = [];
for (const group of groupMentions) {
if (mentionedGroup.includes(group.modelId)) {
continue;
}
// Check if the group has mentions disabled
const groupModel = await Group.findByPk(group.modelId);
if (groupModel?.disableMentions) {
continue;
}
const usersFromMentionedGroup = await GroupUser.findAll({
where: {
groupId: group.modelId,
},
order: [["permission", "ASC"]],
});
const mentionedUser: string[] = [];
for (const user of usersFromMentionedGroup) {
if (mentionedUser.includes(user.userId)) {
continue;
}
const recipient = await User.findByPk(user.userId);
if (
recipient &&
recipient.id !== group.actorId &&
recipient.subscribedToEventType(
NotificationEventType.GroupMentionedInDocument
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.GroupMentionedInDocument,
groupId: group.modelId,
userId: recipient.id,
actorId: group.actorId,
teamId: document.teamId,
documentId: document.id,
});
mentionedUser.push(user.userId);
}
}
mentionedGroup.push(group.modelId);
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients({
document,
+22 -29
View File
@@ -36,43 +36,36 @@ export default abstract class ExportTask extends BaseTask<Props> {
const fileOperation = await FileOperation.findByPk(fileOperationId, {
rejectOnEmpty: true,
});
const [team, user] = await Promise.all([
Team.findByPk(fileOperation.teamId, { rejectOnEmpty: true }),
User.findByPk(fileOperation.userId, { rejectOnEmpty: true }),
]);
const where: WhereOptions<Collection> = fileOperation.collectionId
? {
teamId: user.teamId,
id: fileOperation.collectionId,
permission: fileOperation.options?.includePrivate
? undefined
: {
[Op.ne]: null,
},
}
: {
teamId: user.teamId,
archivedAt: {
[Op.eq]: null,
},
permission: fileOperation.options?.includePrivate
? undefined
: {
[Op.ne]: null,
},
};
const collections = await Collection.scope("withDocumentStructure").findAll(
{
where,
}
);
let filePath: string | undefined;
try {
const where: WhereOptions<Collection> = {
teamId: user.teamId,
};
if (!fileOperation.options?.includePrivate) {
where.permission = {
[Op.ne]: null,
};
}
if (fileOperation.collectionId) {
where.id = fileOperation.collectionId;
} else {
where.archivedAt = {
[Op.eq]: null,
};
}
const collections = await Collection.scope(
"withDocumentStructure"
).findAll({ where });
if (!fileOperation.collectionId) {
const totalAttachmentsSize = await Attachment.getTotalSizeForTeam(
user.teamId
@@ -5,7 +5,14 @@ import { MentionType, NotificationEventType } from "@shared/types";
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { Document, Revision, Notification, User, View } from "@server/models";
import {
Document,
Revision,
Notification,
User,
View,
GroupUser,
} from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { RevisionEvent } from "@server/types";
@@ -38,20 +45,23 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
// Send notifications to mentioned users first
const oldMentions = before
? DocumentHelper.parseMentions(before, { type: MentionType.User })
? [...DocumentHelper.parseMentions(before, { type: MentionType.User })]
: [];
const newMentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
const newMentions = [
...DocumentHelper.parseMentions(document, {
type: MentionType.User,
}),
];
const mentions = differenceBy(newMentions, oldMentions, "id");
const userIdsMentioned: string[] = [];
for (const mention of mentions) {
if (userIdsMentioned.includes(mention.modelId)) {
continue;
}
const recipient = await User.findByPk(mention.modelId);
if (
recipient &&
recipient.id !== mention.actorId &&
@@ -68,10 +78,68 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
teamId: document.teamId,
documentId: document.id,
});
userIdsMentioned.push(recipient.id);
}
}
// send notifications to users in mentioned groups
const oldGroupMentions = before
? DocumentHelper.parseMentions(before, { type: MentionType.Group })
: [];
const newGroupMentions = DocumentHelper.parseMentions(document, {
type: MentionType.Group,
});
const groupMentions = differenceBy(
newGroupMentions,
oldGroupMentions,
"id"
);
const mentionedGroup: string[] = [];
for (const group of groupMentions) {
if (mentionedGroup.includes(group.modelId)) {
continue;
}
const usersFromMentionedGroup = await GroupUser.findAll({
where: {
groupId: group.modelId,
},
order: [["permission", "ASC"]],
});
const mentionedUser: string[] = [];
for (const user of usersFromMentionedGroup) {
if (mentionedUser.includes(user.userId)) {
continue;
}
const recipient = await User.findByPk(user.userId);
if (
recipient &&
recipient.id !== group.actorId &&
recipient.subscribedToEventType(
NotificationEventType.GroupMentionedInDocument
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.GroupMentionedInDocument,
groupId: group.modelId,
userId: recipient.id,
revisionId: event.modelId,
actorId: group.actorId,
teamId: document.teamId,
documentId: document.id,
});
mentionedUser.push(user.userId);
}
}
mentionedGroup.push(group.modelId);
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients({
document,
+14 -1
View File
@@ -250,7 +250,20 @@ router.post(
{ type: MentionType.User }
).map((mention) => mention.id);
newMentionIds = difference(updatedMentionIds, existingMentionIds);
const existingGroupMentionIds = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(comment.data),
{ type: MentionType.Group }
).map((mention) => mention.id);
const updatedGroupMentionIds = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(data),
{ type: MentionType.Group }
).map((mention) => mention.id);
newMentionIds = [
...difference(updatedMentionIds, existingMentionIds),
...difference(updatedGroupMentionIds, existingGroupMentionIds),
];
comment.data = data;
}

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