Compare commits

...

34 Commits

Author SHA1 Message Date
tommoor 18aadd2b5a chore: Compressed inefficient images automatically 2025-09-28 20:05:54 +00:00
codegen-sh[bot] 69e07a9c21 Restrict document permanent deletion to admins only (#10254)
* Restrict document permanent deletion to admins only

- Split the document policy to separate restore and permanentDelete permissions
- permanentDelete now requires isTeamAdmin check
- Added test to verify non-admin users cannot permanently delete documents
- Updated existing test title for clarity

Fixes #10249

* Fix test: expect 'Authorization error' instead of 'Authorization required'

The policy system throws AuthorizationError with message 'Authorization error'
when authorization fails, not 'Authorization required'.

* Trigger CI: Code analysis confirms implementation is correct

- Policy correctly restricts permanentDelete to team admins only
- Test implementation follows established patterns and expects correct error message
- Linting and TypeScript compilation pass successfully
- Issue appears to be environmental rather than code-related

* Clean up temporary CI trigger file

* Fix failing tests after restricting permanent delete to admins

- Update tests to expect permanentDelete ability to be false for regular users
- Add test to verify admin users still have permanentDelete abilities
- This aligns with the policy changes that restrict permanent deletion to team admins only

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-26 00:55:55 -04:00
Luke Granger-Brown 7b27b74e24 fix: Recognise authentication_required for some OIDC providers (#10252)
Some OIDC providers return 401 Unauthorized errors with an empty body
when the access token has expired.

Avoid trying to parse the body as JSON before we've checked whether the
status code is OK.

Fixes #10251.
2025-09-26 00:52:53 -04:00
Tom Moor 92db179230 chore: Add script to reset encrypted data (#10238)
* chore: Add script to reset encrypted data

* Update server/models/WebhookSubscription.ts

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* Update server/scripts/reset-encrypted-data.ts

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* Add confirmation

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-09-23 08:34:46 -04:00
Tom Moor fa93092f79 fix: /desktop-redirect should never be post-login saved path (#10237) 2025-09-22 21:59:09 -04:00
Tom Moor 63c5938a43 fix: Allow new DATABASE_ env variables to work with migrations/creation (#10216)
* fix: Allow new DATABASE_ env variables to work with migrations/db creation

* Revert
2025-09-22 20:43:27 -04:00
dependabot[bot] a4f77e4438 chore(deps): bump the aws group with 5 updates (#10231)
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.888.0` | `3.893.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.888.0` | `3.893.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.888.0` | `3.893.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.888.0` | `3.893.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.888.0` | `3.893.0` |


Updates `@aws-sdk/client-s3` from 3.888.0 to 3.893.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.893.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.888.0 to 3.893.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.893.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.888.0 to 3.893.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.893.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.888.0 to 3.893.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.893.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.888.0 to 3.893.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.893.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.893.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.893.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.893.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.893.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.893.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-09-22 19:58:01 -04:00
dependabot[bot] 7bb8ff4797 chore(deps-dev): bump @types/react-virtualized-auto-sizer (#10233)
Bumps [@types/react-virtualized-auto-sizer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-virtualized-auto-sizer) from 1.0.4 to 1.0.8.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-virtualized-auto-sizer)

---
updated-dependencies:
- dependency-name: "@types/react-virtualized-auto-sizer"
  dependency-version: 1.0.8
  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-09-22 19:57:53 -04:00
Tom Moor bcdedd53d8 events.list (#10229) 2025-09-22 11:34:21 +00:00
Benjamin 37b18ab940 chore: Update emoji.ts, fix typos in emoji utilities (#10228)
Typos fixed are:

- "convas" -> "canvas"
- "show be" -> "should be"
- "Get am emoji" -> "Get an emoji"
2025-09-22 07:30:47 -04:00
Tom Moor 42d699fabe fix: Add additional checks to userInviter (#10226) 2025-09-22 07:26:28 -04:00
Tom Moor 8026dac146 fix: Non-reactive facepiles on group settings table (#10227) 2025-09-22 07:26:16 -04:00
Tom Moor a5e1f613fc chore: Remove reakit (#10219)
* First pass

* Remove reakit

* fix focus issues

* fix: Misalignment of toolbar after changing code language
2025-09-22 07:25:56 -04:00
Tom Moor 3d1f55b605 fix: size of undefined in ImportProcessor (#10225) 2025-09-21 21:31:28 +00:00
Tom Moor f25ba44477 fix: cap height of popovers (#10218) 2025-09-20 12:31:41 -04:00
Tom Moor cc052f75d1 fix: Expose originalDocumentId on sourceMetadata (#10217) 2025-09-20 12:22:15 -04:00
codegen-sh[bot] 38beca412e Add originalDocumentId to sourceMetadata when duplicating documents (#10215)
- Added originalDocumentId property to SourceMetadata type
- Updated documentDuplicator to set originalDocumentId for both parent and child documents
- Added comprehensive tests to verify the functionality works correctly
- Preserves existing sourceMetadata while adding the new property

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-20 08:34:28 -04:00
Tom Moor fec16346db feat: Add six new shape icons (#10203)
fix: Better default color in IconPicker for dark theme
2025-09-17 23:39:21 -04:00
Tom Moor ca084c485d fix: Comment sidebar bouncing in and out (#10202) 2025-09-17 23:21:59 -04:00
Tom Moor 5d871beee2 v0.87.4 2025-09-17 20:38:17 -04:00
Tom Moor cf46f3e6ee fix: Workaround for validation error on Notion imports (#10200) 2025-09-17 20:35:58 -04:00
Translate-O-Tron 0784b9569c New Crowdin updates (#10166)
* fix: New Dutch translations from Crowdin [ci skip]

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2025-09-17 09:12:13 -04:00
codegen-sh[bot] f3076ed418 Fix shares_collectionId_fkey constraint error with CASCADE delete (#10196)
* Fix foreign key constraint error for shares.collectionId

- Add migration to update shares_collectionId_fkey constraint with CASCADE delete
- This fixes the constraint violation when deleting collections that have associated shares
- Follows the same pattern as the existing shares_documentId_fkey cascade fix

Fixes #10185

* Simplify migration by removing unnecessary loop and constraint array

- Remove loop and constraintNames array since there's only one constraint
- Directly reference 'shares_collectionId_fkey' constraint name
- Keep the same functionality but with cleaner, simpler code

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-17 08:57:16 -04:00
codegen-sh[bot] 70321350d4 Add TeamPreference to prevent document embedding (#10192)
* Add TeamPreference to prevent document embedding

- Add PreventDocumentEmbedding enum value to TeamPreference
- Add default value (false) to maintain backward compatibility
- Update TeamPreferences interface with new boolean property
- Modify renderShare function to respect team preference
- When preference is true, X-Frame-Options header is kept to prevent iframe embedding
- When preference is false (default), X-Frame-Options is removed to allow embedding

* Add preventDocumentEmbedding to TeamsUpdateSchema

- Add preventDocumentEmbedding boolean field to the preferences object in TeamsUpdateSchema
- This allows the new TeamPreference to be modified through the API
- The existing teamUpdater function will automatically handle the new preference
- API responses will include the preference via the presentTeam function

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-16 21:36:16 -04:00
Tom Moor fc6d1a9be4 fix: Enter in empty heading (#10194) 2025-09-16 21:36:07 -04:00
dependabot[bot] 07694e06ff chore(deps): bump the babel group with 2 updates (#10189)
Bumps the babel group with 2 updates: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) and [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator).


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

Updates `@babel/plugin-transform-regenerator` from 7.28.3 to 7.28.4
- [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.4/packages/babel-plugin-transform-regenerator)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.28.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-version: 7.28.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-16 20:49:26 -04:00
Tom Moor 5d1987fb0d chore: Update README (#10191) 2025-09-17 00:18:19 +00:00
Tom Moor 9bc6ae44ad fix: code cannot contain LaTeX syntax (#10179) 2025-09-15 20:58:20 -04:00
dependabot[bot] 792fd96f38 chore(deps-dev): bump @types/passport-oauth2 from 1.4.17 to 1.8.0 (#10186)
Bumps [@types/passport-oauth2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/passport-oauth2) from 1.4.17 to 1.8.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/passport-oauth2)

---
updated-dependencies:
- dependency-name: "@types/passport-oauth2"
  dependency-version: 1.8.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-09-15 20:42:24 -04:00
dependabot[bot] b1011af016 chore(deps): bump the aws group with 5 updates (#10187)
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.879.0` | `3.888.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.879.0` | `3.888.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.879.0` | `3.888.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.879.0` | `3.888.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.879.0` | `3.888.0` |


Updates `@aws-sdk/client-s3` from 3.879.0 to 3.888.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.888.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.879.0 to 3.888.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.888.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.879.0 to 3.888.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.888.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.879.0 to 3.888.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.888.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.879.0 to 3.888.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.888.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.888.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.888.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.888.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.888.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.888.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-09-15 20:41:24 -04:00
dependabot[bot] 69d6140ab3 chore(deps): bump ioredis from 5.6.0 to 5.7.0 (#10188)
Bumps [ioredis](https://github.com/luin/ioredis) from 5.6.0 to 5.7.0.
- [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.6.0...v5.7.0)

---
updated-dependencies:
- dependency-name: ioredis
  dependency-version: 5.7.0
  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-09-15 20:40:46 -04:00
Tom Moor 01f1de21a8 fix: Allow documents imported in separate imports to retain links between (#10182) 2025-09-15 09:32:52 -04:00
Tom Moor 491442d20f Quick win - allow theme override with query string (#10180) 2025-09-14 21:19:02 -04:00
Tom Moor 7303970118 feat: Display right sidebar as drawer on mobile (#10175)
* wip

* Stack metadata on mobile

* fix: Allow viewing history
2025-09-14 17:09:33 -04:00
125 changed files with 2117 additions and 2582 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ require("@dotenvx/dotenvx").config({
var path = require('path');
module.exports = {
'config': path.resolve('server/config', 'database.json'),
'config': path.resolve('server/config', 'database.js'),
'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'),
}
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.87.3
Licensed Work: Outline 0.87.4
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-01
Change Date: 2029-09-18
Change License: Apache License, Version 2.0
+12 -11
View File
@@ -7,14 +7,13 @@
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
</p>
<p align="center">
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, A hosted version of the app is offered at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
@@ -51,13 +50,14 @@ please refer to the [architecture document](docs/ARCHITECTURE.md) first for a hi
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable. logging
can be enabled for all categories by setting `DEBUG=*` or for specific categories such as `DEBUG=database` and `LOG_LEVEL=debug`, or `LOG_LEVEL=silly` for very verbose logging.
## Tests
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
```shell
# To run all tests
@@ -68,14 +68,14 @@ make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
frontend and backend tests directly with jest:
```shell
# To run backend tests
yarn test:server
# To run a specific backend test
yarn test:server myTestFile
# To run a specific backend test in watch mode
yarn test path/to/file.test.ts --watch
# To run frontend tests
yarn test:app
@@ -86,14 +86,15 @@ yarn test:app
Sequelize is used to create and run migrations, for example:
```shell
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
yarn db:create-migration --name my-migration
yarn db:migrate
yarn db:rollback
```
Or to run migrations on test database:
Or, to run migrations on test database:
```shell
yarn sequelize db:migrate --env test
yarn db:migrate --env test
```
# Activity
-7
View File
@@ -13,13 +13,6 @@
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
],
"paths": [
{
"name": "reakit/Menu",
"importNames": ["useMenuState"],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
]
+4 -7
View File
@@ -13,7 +13,6 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
@@ -109,12 +108,10 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
>
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</Route>
)}
</AnimatePresence>
-13
View File
@@ -1,13 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${s("sidebarText")};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;
@@ -1,13 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export default MenuIconWrapper;
-217
View File
@@ -1,217 +0,0 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: LocationDescriptor;
href?: string;
target?: string;
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
disabled,
as,
hide,
icon,
...rest
}: Props,
ref: React.Ref<HTMLAnchorElement>
) => {
const content = React.useCallback(
(props) => {
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const handleClick = async (ev: React.MouseEvent) => {
hide?.();
if (onClick) {
preventDefault(ev);
await onClick(ev);
}
};
return (
<MenuAnchor
{...props}
$active={active}
as={onClick ? "button" : as}
onClick={handleClick}
onPointerDown={preventDefault}
onMouseDown={preventDefault}
ref={mergeRefs([
ref,
props.ref as React.RefObject<HTMLAnchorElement>,
])}
>
{selected !== undefined && (
<SelectedWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : <Spacer />}
</SelectedWrapper>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
</MenuAnchor>
);
},
[active, as, hide, icon, onClick, ref, children, selected]
);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
>
{content}
</BaseMenuItem>
);
};
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
level?: number;
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
$active?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
display: flex;
margin: 0;
border: 0;
padding: 12px;
border-radius: 4px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
background: none;
color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
font-size: 16px;
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) => props.disabled && "pointer-events: none;"}
${(props) =>
props.$active === undefined &&
!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);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
}
}
`}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${props.theme.accentText};
}
`}
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`}
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
const SelectedWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -8px;
flex-shrink: 0;
color: ${s("textSecondary")};
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
@@ -1,70 +0,0 @@
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
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 default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
x = 0,
y = 0,
height: h = 0,
width: w = 0,
} = props.parentRef.current?.getBoundingClientRect() || {};
const [mouseX, mouseY] = useMousePosition();
const positions = { x, y, h, w, mouseX, mouseY };
return (
<div
style={{
position: "absolute",
top: 0,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
right: getRight(positions),
left: getLeft(positions),
height: h,
width: getWidth(positions),
clipPath: getClipPath(positions),
}}
/>
);
}
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w), 10) + "px"
: Math.max(x - mouseX, 10) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
(100 * (mouseY - y)) / h + 5
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
@@ -1,27 +0,0 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof MenuButton> & {
className?: string;
};
export default function OverflowMenuButton({ className, ...rest }: Props) {
const { t } = useTranslation();
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton
className={className}
aria-label={t("More options")}
{...props}
>
<MoreIcon />
</NudeButton>
)}
</MenuButton>
);
}
-15
View File
@@ -1,15 +0,0 @@
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 6px 0;
`;
-264
View File
@@ -1,264 +0,0 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
MenuButton,
MenuItem as BaseMenuItem,
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import {
Action,
ActionContext,
MenuSeparator,
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
`;
type SubMenuProps = MenuStateReturn & {
templateItems: TMenuItem[];
parentMenuState: Omit<MenuStateReturn, "items">;
title: React.ReactNode;
};
const SubMenu = React.forwardRef(function _Template(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
ref: React.LegacyRef<HTMLButtonElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState({
parentId: parentMenuState.baseId,
});
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
parentMenuState={parentMenuState}
>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
}
function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({
isMenu: true,
});
const templateItems = actions
? actions.map((item) =>
item.type === "separator" || item.type === "heading"
? item
: actionToMenuItem(item, ctx)
)
: items || [];
const filteredTemplates = filterTemplateItems(templateItems);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) =>
item.type !== "separator" && item.type !== "heading" && !!item.icon
);
return (
<>
{filteredTemplates.map((item, index) => {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading" &&
showIcons !== false
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
}
if (item.type === "route") {
return (
<MenuItem
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "link") {
return (
<MenuItem
id={`${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={
typeof item.href === "string" ? undefined : item.href.target
}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "button") {
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={`${item.type}-${item.title}-${index}`}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
}
if (item.type === "submenu") {
// Skip rendering empty submenus
return item.items.length > 0 ? (
<BaseMenuItem
key={`${item.type}-${item.title}-${index}`}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
parentMenuState={menu}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu}
/>
) : null;
}
if (item.type === "separator") {
return <Separator key={`separator-${index}`} />;
}
if (item.type === "heading") {
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
}
// This should never be reached for Reakit dropdown menu.
// Added for exhaustiveness check.
if (item.type === "group") {
return null;
}
const _exhaustiveCheck: never = item;
return _exhaustiveCheck;
})}
</>
);
}
function Title({
title,
icon,
}: {
title: React.ReactNode;
icon?: React.ReactNode;
}) {
return (
<Flex align="center">
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
-317
View File
@@ -1,317 +0,0 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuStateReturn } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "~/styles/animations";
export type Placement =
| "auto-start"
| "auto"
| "auto-end"
| "top-start"
| "top"
| "top-end"
| "right-start"
| "right"
| "right-end"
| "bottom-end"
| "bottom"
| "bottom-start"
| "left-end"
| "left"
| "left-start";
type Props = MenuStateReturn & {
"aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */
onOpen?: () => void;
/** Called when the context menu is closed. */
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
/** The minimum height of the context menu. */
minHeight?: number;
children?: React.ReactNode;
};
const ContextMenu: React.FC<Props> = ({
menuRef,
children,
onOpen,
onClose,
parentMenuState,
...rest
}: Props) => {
const previousVisible = usePrevious(rest.visible);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
const isSubMenu = !!parentMenuState;
useUnmount(() => {
setIsMenuOpen(false);
});
React.useEffect(() => {
if (rest.visible && !previousVisible) {
onOpen?.();
if (!isSubMenu) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
onClose?.();
if (!isSubMenu) {
setIsMenuOpen(false);
}
}
}, [
onOpen,
onClose,
previousVisible,
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
isSubMenu,
t,
]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
}
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu
ref={menuRef}
hideOnClickOutside={!isMobile}
preventBodyScroll={false}
{...rest}
>
{(props) => (
<InnerContextMenu
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
>
{children}
</InnerContextMenu>
)}
</Menu>
</>
);
};
type InnerContextMenuProps = MenuStateReturn & {
isSubMenu: boolean;
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
* Inner context menu allows deferring expensive window measurement hooks etc
* until the menu is actually opened.
*/
const InnerContextMenu = (props: InnerContextMenuProps) => {
const { menuProps } = props;
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor =
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
const rightAnchor = menuProps.placement === "bottom-end";
const backgroundRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
const maxHeight = useMenuHeight({
visible: props.visible,
elementRef: props.unstable_disclosureRef,
});
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (props.visible && scrollElement && !props.isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
if (scrollElement && !props.isSubMenu) {
enableBodyScroll(scrollElement);
}
};
}, [props.isSubMenu, props.visible]);
useEventListener(
"animationstart",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "none";
}
}
},
backgroundRef.current
);
useEventListener(
"animationend",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "auto";
}
}
},
backgroundRef.current
);
const style =
topAnchor && !isMobile
? {
maxHeight,
}
: undefined;
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
props.hide?.();
}}
/>
)}
<Position {...menuProps}>
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={style}
>
{props.visible || props.animating ? props.children : null}
</Background>
</Position>
</>
);
};
export default ContextMenu;
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${s("backdrop")};
z-index: ${depths.menu - 1};
`;
export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
// Note: pointer events are re-enabled after the animation ends, see event listeners above
pointer-events: none;
&:focus-visible {
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
outline: none;
}
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
*/
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
top: auto !important;
right: 8px !important;
bottom: 16px !important;
left: 8px !important;
`};
`;
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
export const Background = styled(Scrollable)<BackgroundProps>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px;
min-width: 180px;
min-height: ${(props) => props.minHeight || 44}px;
max-height: 75vh;
font-weight: normal;
@media print {
display: none;
}
${breakpoint("tablet")`
animation: ${(props: BackgroundProps) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
max-height: 100vh;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
`;
+16 -5
View File
@@ -155,14 +155,16 @@ const DocumentMeta: React.FC<Props> = ({
}
return (
<Viewed>
&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
<Separator />
<Modified highlight>{t("Never viewed")}</Modified>
</Viewed>
);
}
return (
<Viewed>
&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
<Separator />
{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
</Viewed>
);
};
@@ -186,16 +188,17 @@ const DocumentMeta: React.FC<Props> = ({
)}
{showParentDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp; {nestedDocumentsCount}{" "}
<Separator />
{nestedDocumentsCount}{" "}
{t("nested document", {
count: nestedDocumentsCount,
})}
</span>
)}
&nbsp;{timeSinceNow()}
{timeSinceNow()}
{canShowProgressBar && (
<>
&nbsp;&nbsp;
<Separator />
<DocumentTasks document={document} />
</>
)}
@@ -204,6 +207,14 @@ const DocumentMeta: React.FC<Props> = ({
);
};
export const Separator = styled.span`
padding: 0 0.4em;
&::after {
content: "•";
}
`;
const Strong = styled.strong`
font-weight: 550;
`;
+2 -3
View File
@@ -6,7 +6,6 @@ import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import NudeButton from "./NudeButton";
@@ -219,9 +218,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
);
const renderOption = React.useCallback(
(option: Option) => {
(option: Option, idx: number) => {
if (option.type === "separator") {
return <Separator />;
return <InputSelectSeparator key={`separator-${idx}`} />;
}
const isSelected = option === selectedOption;
+27 -30
View File
@@ -12,7 +12,6 @@ import SkipNavLink from "~/components/SkipNavLink";
import env from "~/env";
import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
type Props = {
@@ -38,41 +37,39 @@ const Layout = React.forwardRef(function Layout_(
});
return (
<MenuProvider>
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<SkipNavLink />
<SkipNavLink />
{ui.progressBarVisible && <LoadingIndicatorBar />}
{ui.progressBarVisible && <LoadingIndicatorBar />}
<Container auto>
<MenuProvider>{sidebar}</MenuProvider>
<Container auto>
{sidebar}
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
{sidebarRight}
</Container>
{sidebarRight}
</Container>
</MenuProvider>
</Container>
);
});
+5 -9
View File
@@ -7,7 +7,6 @@ import { depths, s } from "@shared/styles";
import ErrorBoundary from "~/components/ErrorBoundary";
import Flex from "~/components/Flex";
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { sidebarAppearDuration } from "~/styles/animations";
@@ -20,7 +19,6 @@ function Right({ children, border, className }: Props) {
const theme = useTheme();
const { ui } = useStores();
const [isResizing, setResizing] = React.useState(false);
const isMobile = useMobile();
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -100,13 +98,11 @@ function Right({ children, border, className }: Props) {
<Sidebar {...animationProps} $border={border} className={className}>
<Position style={style} column>
<ErrorBoundary>{children}</ErrorBoundary>
{!isMobile && (
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={handleReset}
dir="right"
/>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={handleReset}
dir="right"
/>
</Position>
</Sidebar>
);
+1 -3
View File
@@ -7,7 +7,6 @@ import { depths, s } from "@shared/styles";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMenuContext from "~/hooks/useMenuContext";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
@@ -41,11 +40,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobile = useMobile();
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const collapsed = ui.sidebarIsClosed;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
+6 -6
View File
@@ -18,6 +18,8 @@ Drawer.displayName = "Drawer";
/** Drawer's trigger. */
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerHandle = DrawerPrimitive.Handle;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
@@ -56,11 +58,9 @@ const DrawerTitle = React.forwardRef<
const { hidden, children, ...rest } = props;
const title = (
<TitleWrapper justify="center">
<Text size="medium" weight="bold">
{children}
</Text>
</TitleWrapper>
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
{children}
</Text>
);
return (
@@ -100,4 +100,4 @@ const TitleWrapper = styled(Flex)`
padding: 8px 0;
`;
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
export { Drawer, DrawerTrigger, DrawerHandle, DrawerContent, DrawerTitle };
+4 -1
View File
@@ -3,7 +3,6 @@ import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { Props as ButtonProps } from "~/components/Button";
import Separator from "~/components/ContextMenu/Separator";
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
import {
SelectItemIndicator,
@@ -99,6 +98,10 @@ const InputSelectSeparator = React.forwardRef<
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
const Separator = styled.hr`
margin: 6px 0;
`;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
+1 -1
View File
@@ -104,7 +104,7 @@ const MenuContent = React.forwardRef<
return (
<Portal>
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
<Content ref={ref} {...offsetProp} {...rest} collisionPadding={6} asChild>
<Components.MenuContent {...contentProps} hiddenScrollbars>
{children}
</Components.MenuContent>
+1 -1
View File
@@ -90,7 +90,7 @@ type StyledContentProps = {
const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
z-index: ${depths.modal};
max-height: var(--radix-popover-content-available-height);
max-height: min(85vh, var(--radix-popover-content-available-height));
transform-origin: var(--radix-popover-content-transform-origin);
background: ${s("menuBackground")};
+20 -1
View File
@@ -9,6 +9,7 @@ import { fadeAndScaleIn } from "~/styles/animations";
type BaseMenuItemProps = {
disabled?: boolean;
$active?: boolean;
$dangerous?: boolean;
};
@@ -44,6 +45,24 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
outline: 0; // Disable default outline on Firefox
}
${(props) =>
props.$active &&
!props.disabled &&
`
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};
}
`}
${(props) =>
!props.disabled &&
`
@@ -58,7 +77,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
+42 -14
View File
@@ -17,6 +17,7 @@ import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
type Props = {
align?: "start" | "end" | "center";
active?: boolean;
children: React.ReactNode;
width?: number;
@@ -35,16 +36,18 @@ const defaultPosition = {
function usePosition({
menuRef,
active,
align = "center",
}: {
menuRef: React.RefObject<HTMLDivElement>;
active?: boolean;
align?: Props["align"];
}) {
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
const menuWidth = menuRef.current?.offsetWidth ?? 0;
const menuHeight = menuRef.current?.offsetHeight ?? 0;
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
if (!active || !menuRef.current) {
return defaultPosition;
}
@@ -94,7 +97,7 @@ function usePosition({
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.left = bounds.right;
selectionBounds.right = bounds.right;
}
}
@@ -180,7 +183,11 @@ function usePosition({
),
Math.max(
Math.max(offsetParent.x, margin),
centerOfSelection - menuWidth / 2
align === "center"
? centerOfSelection - menuWidth / 2
: align === "start"
? selectionBounds.left
: selectionBounds.right
)
);
const top = Math.max(
@@ -216,6 +223,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
let position = usePosition({
menuRef,
active: props.active,
align: props.align,
});
if (isSelectingText) {
@@ -277,7 +285,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
left: `${position.left}px`,
}}
>
{props.children}
<Background align={props.align}>{props.children}</Background>
</Wrapper>
</Portal>
);
@@ -302,7 +310,7 @@ const arrow = (props: WrapperProps) =>
border-radius: 3px;
z-index: -1;
position: absolute;
bottom: -2px;
bottom: -3px;
left: calc(50% - ${props.$offset || 0}px);
pointer-events: none;
}
@@ -335,22 +343,42 @@ const MobileWrapper = styled.div`
}
`;
const Wrapper = styled.div<WrapperProps>`
will-change: opacity, transform;
padding: 6px;
position: absolute;
z-index: ${depths.editorToolbar};
opacity: 0;
const Background = styled.div<{ align: Props["align"] }>`
position: relative;
background-color: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 4px;
height: 36px;
padding: 6px;
${(props) =>
props.align === "start" &&
`
position: absolute;
left: 0;
bottom: 0;
`}
${(props) =>
props.align === "end" &&
`
position: absolute;
right: 0;
bottom: 0;
`}
`;
const Wrapper = styled.div<WrapperProps>`
will-change: opacity, transform;
position: absolute;
z-index: ${depths.editorToolbar};
opacity: 0;
transform: scale(0.95);
transition:
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
height: 36px;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
@@ -199,9 +199,11 @@ export default function SelectionToolbar(props: Props) {
const isNoticeSelection = isInNotice(state);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelection) {
items = getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
@@ -220,6 +222,7 @@ export default function SelectionToolbar(props: Props) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
align = "end";
} else {
items = getFormattingMenuItems(state, isTemplate, dictionary);
}
@@ -251,6 +254,7 @@ export default function SelectionToolbar(props: Props) {
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
+4 -2
View File
@@ -14,13 +14,13 @@ import { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Header from "~/components/ContextMenu/Header";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import Input from "./Input";
import { MenuHeader } from "~/components/primitives/components/Menu";
type TopAnchor = {
top: number;
@@ -647,7 +647,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
<MenuHeader key={currentHeading}>
{currentHeading}
</MenuHeader>
)}
<ListItem
onPointerMove={handlePointerMove}
+14 -9
View File
@@ -2,8 +2,8 @@ import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import MenuItem from "~/components/ContextMenu/MenuItem";
import { usePortalContext } from "~/components/Portal";
import { MenuButton, MenuLabel } from "~/components/primitives/components/Menu";
export type Props = {
/** Whether the item is selected */
@@ -53,17 +53,22 @@ function SuggestionsMenuItem({
);
return (
<MenuItem
<MenuButton
ref={ref}
active={selected}
onClick={disabled ? undefined : onClick}
disabled={disabled}
onClick={onClick}
onPointerMove={disabled ? undefined : onPointerMove}
icon={icon}
$active={selected}
>
{title}
{subtitle && <Subtitle $active={selected}>&middot; {subtitle}</Subtitle>}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuItem>
{icon}
<MenuLabel>
{title}
{subtitle && (
<Subtitle $active={selected}>&middot; {subtitle}</Subtitle>
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
</MenuButton>
);
}
+71 -55
View File
@@ -1,12 +1,9 @@
import { useMemo } from "react";
import { useMenuState } from "reakit";
import { MenuButton } from "reakit/Menu";
import { useCallback, useMemo } from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Toolbar from "@radix-ui/react-toolbar";
import { MenuItem } from "@shared/editor/types";
import { s } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { TooltipProvider } from "~/components/TooltipContext";
import { MenuItem as TMenuItem } from "~/types";
import { useEditor } from "./EditorContext";
@@ -14,6 +11,12 @@ import { MediaDimension } from "./MediaDimension";
import ToolbarButton from "./ToolbarButton";
import ToolbarSeparator from "./ToolbarSeparator";
import Tooltip from "./Tooltip";
import { toMenuItems } from "~/components/Menu/transformer";
import { MenuContent } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { Menu, MenuTrigger } from "~/components/primitives/Menu";
import { useTranslation } from "react-i18next";
import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
@@ -23,8 +26,8 @@ type Props = {
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const menu = useMenuState();
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item } = props;
const { state } = view;
@@ -60,24 +63,30 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
: [];
}, [item.children, commands, state]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
return (
<>
<MenuButton {...menu}>
{(buttonProps) => (
<ToolbarButton
{...buttonProps}
hovering={menu.visible}
aria-label={item.tooltip}
<EventBoundary>
<MenuProvider variant="dropdown">
<Menu>
<MenuTrigger>
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</MenuTrigger>
<MenuContent
align="end"
aria-label={item.tooltip || t("More options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</MenuButton>
<ContextMenu aria-label={item.label} {...menu}>
<Template {...menu} items={items} />
</ContextMenu>
</>
{toMenuItems(items)}
</MenuContent>
</Menu>
</MenuProvider>
</EventBoundary>
);
}
@@ -98,40 +107,47 @@ function ToolbarMenu(props: Props) {
return (
<TooltipProvider>
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
<Toolbar.Root asChild>
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown active={isActive && !item.label} item={item} />
) : (
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
active={isActive && !item.label}
item={item}
/>
) : (
<Toolbar.Button asChild>
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</Toolbar.Button>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
</Toolbar.Root>
</TooltipProvider>
);
}
+4 -1
View File
@@ -9,6 +9,7 @@ import { CustomTheme } from "@shared/types";
import type { Theme } from "~/stores/UiStore";
import useMediaQuery from "~/hooks/useMediaQuery";
import useStores from "./useStores";
import useQuery from "./useQuery";
/**
* Builds a theme based on the current user's preferences, the current device
@@ -23,9 +24,11 @@ export default function useBuildTheme(
overrideTheme?: Theme
) {
const { ui } = useStores();
const params = useQuery();
const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`);
const isPrinting = useMediaQuery("print");
const resolvedTheme = overrideTheme ?? ui.resolvedTheme;
const queryTheme = (params.get("theme") as Theme) || undefined;
const resolvedTheme = overrideTheme ?? queryTheme ?? ui.resolvedTheme;
const theme = useMemo(
() =>
-75
View File
@@ -1,75 +0,0 @@
import noop from "lodash/noop";
import * as React from "react";
type MenuContextType = {
isMenuOpen: boolean;
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
registerMenu: (menuId: string, hideFunction: () => void) => void;
unregisterMenu: (menuId: string) => void;
closeOtherMenus: (...menuIds: (string | undefined)[]) => void;
};
const MenuContext = React.createContext<MenuContextType | null>(null);
// Registry to track all active menu instances
const menuRegistry = new Map();
type Props = {
children?: React.ReactNode;
};
export const MenuProvider: React.FC = ({ children }: Props) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const registerMenu = React.useCallback(
(menuId: string, hideFunction: () => void) => {
menuRegistry.set(menuId, hideFunction);
},
[]
);
const unregisterMenu = React.useCallback((menuId: string) => {
menuRegistry.delete(menuId);
}, []);
const closeOtherMenus = React.useCallback(
(...menuIds: (string | undefined)[]) => {
menuRegistry.forEach((hideFunction, menuId) => {
if (!menuIds.includes(menuId)) {
hideFunction();
}
});
},
[]
);
const memoized = React.useMemo(
() => ({
isMenuOpen,
setIsMenuOpen,
registerMenu,
unregisterMenu,
closeOtherMenus,
}),
[isMenuOpen, setIsMenuOpen, registerMenu, unregisterMenu, closeOtherMenus]
);
return (
<MenuContext.Provider value={memoized}>{children}</MenuContext.Provider>
);
};
const useMenuContext: () => MenuContextType = () => {
const value = React.useContext(MenuContext);
return value
? value
: {
isMenuOpen: false,
setIsMenuOpen: noop,
registerMenu: noop,
unregisterMenu: noop,
closeOtherMenus: noop,
};
};
export default useMenuContext;
-46
View File
@@ -1,46 +0,0 @@
import * as React from "react";
import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
const useMenuHeight = ({
visible,
elementRef,
maxViewportHeight = 90,
margin = 8,
}: {
/** Whether the menu is visible. */
visible: void | boolean;
/** The maximum height of the menu as a percentage of the viewport. */
maxViewportHeight?: number;
/** A ref pointing to the element for the menu disclosure. */
elementRef?: React.RefObject<HTMLElement | null>;
/** The margin to apply to the positioning. */
margin?: number;
}) => {
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(10);
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useLayoutEffect(() => {
if (visible && !isMobile) {
const calculatedMaxHeight = (windowHeight / 100) * maxViewportHeight;
setMaxHeight(
Math.min(
calculatedMaxHeight,
elementRef?.current
? windowHeight -
elementRef.current.getBoundingClientRect().bottom -
margin
: 0
)
);
} else {
setMaxHeight(0);
}
}, [visible, elementRef, windowHeight, margin, isMobile, maxViewportHeight]);
return maxHeight;
};
export default useMenuHeight;
-48
View File
@@ -1,48 +0,0 @@
import * as React from "react";
import {
// oxlint-disable-next-line no-restricted-imports
useMenuState as reakitUseMenuState,
MenuStateReturn,
} from "reakit/Menu";
import useMenuContext from "./useMenuContext";
type Props = Parameters<typeof reakitUseMenuState>[0] & {
parentId?: string;
};
/**
* A hook that wraps Reakit's useMenuState with coordination logic to ensure
* only one context menu can be open at a time across the application.
*/
export function useMenuState(options?: Props): MenuStateReturn {
const menuState = reakitUseMenuState(options);
const { registerMenu, unregisterMenu, closeOtherMenus } = useMenuContext();
const menuId = menuState.baseId;
const parentId = options?.parentId;
// Register this menu instance on mount and unregister on unmount
React.useEffect(() => {
registerMenu(menuId, menuState.hide);
return () => unregisterMenu(menuId);
}, [menuId, menuState.hide, registerMenu, unregisterMenu]);
const coordinatedShow = React.useCallback(() => {
closeOtherMenus(menuId, parentId);
menuState.show();
}, [closeOtherMenus, menuId, menuState, parentId]);
const coordinatedToggle = React.useCallback(() => {
closeOtherMenus(menuId, parentId);
menuState.toggle();
}, [menuId, menuState, closeOtherMenus, parentId]);
// Return the menu state with the coordinated show method
return React.useMemo(
() => ({
...menuState,
toggle: coordinatedToggle,
show: coordinatedShow,
}),
[menuState, coordinatedToggle, coordinatedShow]
);
}
-33
View File
@@ -1,33 +0,0 @@
import throttle from "lodash/throttle";
import { useState, useMemo } from "react";
import useEventListener from "./useEventListener";
import useIsMounted from "./useIsMounted";
/**
* 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 isMounted = useIsMounted();
const [mousePosition, setMousePosition] = useState<MousePosition>([0, 0]);
const updateMousePosition = useMemo(
() =>
throttle((ev: MouseEvent) => {
if (isMounted()) {
setMousePosition([ev.clientX, ev.clientY]);
}
}, 200),
[isMounted]
);
useEventListener("mousemove", updateMousePosition);
return mousePosition;
};
+4 -4
View File
@@ -53,8 +53,8 @@ if (element) {
<HelmetProvider>
<Provider {...stores}>
<Analytics>
<Theme>
<Router history={history}>
<Router history={history}>
<Theme>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
@@ -74,8 +74,8 @@ if (element) {
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</Router>
</Theme>
</Theme>
</Router>
</Analytics>
</Provider>
</HelmetProvider>
+2
View File
@@ -4,6 +4,7 @@ import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
import Field from "./decorators/Field";
import { observable } from "mobx";
/**
* Represents a user's membership to a group.
@@ -27,6 +28,7 @@ class GroupUser extends Model {
/** The permission of the user in the group. */
@Field
@observable
permission: GroupPermission;
}
+2 -1
View File
@@ -48,6 +48,7 @@ import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import ShareButton from "./components/ShareButton";
import first from "lodash/first";
const IconPicker = lazy(() => import("~/components/IconPicker"));
@@ -206,7 +207,7 @@ const CollectionScene = observer(function _CollectionScene() {
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? colorPalette[0]}
color={collection.color ?? (first(colorPalette) as string)}
initial={collection.initial}
size={40}
popoverPosition="bottom-start"
+67 -59
View File
@@ -23,6 +23,7 @@ import CommentForm from "./CommentForm";
import CommentSortMenu from "./CommentSortMenu";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";
import useMobile from "~/hooks/useMobile";
import { ArrowDownIcon } from "~/components/Icons/ArrowIcon";
function Comments() {
@@ -34,6 +35,8 @@ function Comments() {
const document = documents.get(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
const isMobile = useMobile();
const query = useQuery();
const [viewingResolved, setViewingResolved] = useState(
query.get("resolved") !== null || focusedComment?.isResolved || false
@@ -123,15 +126,73 @@ function Comments() {
prevThreadCount.current = threads.length;
}, [sortOption.type, threads.length, viewingResolved]);
if (!document || !isEditorInitialized) {
return null;
}
const content =
!document || !isEditorInitialized ? null : (
<>
<Scrollable
id="comments"
bottomShadow={!focusedComment}
hiddenScrollbars
topShadow
ref={scrollableRef}
onScroll={handleScroll}
>
<Wrapper $hasComments={hasComments}>
{hasComments ? (
threads.map((thread) => (
<CommentThread
key={thread.id}
comment={thread}
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
/>
))
) : (
<NoComments align="center" justify="center" auto>
<PositionedEmpty>
{viewingResolved
? t("No resolved comments")
: t("No comments yet")}
</PositionedEmpty>
</NoComments>
)}
{showJumpToRecentBtn && (
<Fade>
<JumpToRecent onClick={scrollToBottom}>
<Flex align="center">
{t("New comments")}&nbsp;
<ArrowDownIcon size={20} />
</Flex>
</JumpToRecent>
</Fade>
)}
</Wrapper>
</Scrollable>
<AnimatePresence initial={false}>
{(!focusedComment || isMobile) && can.comment && !viewingResolved && (
<NewCommentForm
draft={draft}
onSaveDraft={onSaveDraft}
documentId={document.id}
placeholder={`${t("Add a comment")}`}
autoFocus={false}
dir={document.dir}
animatePresence
standalone
/>
)}
</AnimatePresence>
</>
);
return (
<Sidebar
title={
<Flex align="center" justify="space-between" auto>
<span>{t("Comments")}</span>
<Flex align="center" justify="space-between" gap={8} auto>
<div style={isMobile ? { padding: "0 8px" } : undefined}>
{t("Comments")}
</div>
<CommentSortMenu
viewingResolved={viewingResolved}
onChange={(val) => {
@@ -143,60 +204,7 @@ function Comments() {
onClose={() => ui.set({ commentsExpanded: false })}
scrollable={false}
>
<Scrollable
id="comments"
bottomShadow={!focusedComment}
hiddenScrollbars
topShadow
ref={scrollableRef}
onScroll={handleScroll}
>
<Wrapper $hasComments={hasComments}>
{hasComments ? (
threads.map((thread) => (
<CommentThread
key={thread.id}
comment={thread}
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
/>
))
) : (
<NoComments align="center" justify="center" auto>
<PositionedEmpty>
{viewingResolved
? t("No resolved comments")
: t("No comments yet")}
</PositionedEmpty>
</NoComments>
)}
{showJumpToRecentBtn && (
<Fade>
<JumpToRecent onClick={scrollToBottom}>
<Flex align="center">
{t("New comments")}&nbsp;
<ArrowDownIcon size={20} />
</Flex>
</JumpToRecent>
</Fade>
)}
</Wrapper>
</Scrollable>
<AnimatePresence initial={false}>
{!focusedComment && can.comment && !viewingResolved && (
<NewCommentForm
draft={draft}
onSaveDraft={onSaveDraft}
documentId={document.id}
placeholder={`${t("Add a comment")}`}
autoFocus={false}
dir={document.dir}
animatePresence
standalone
/>
)}
</AnimatePresence>
{content}
</Sidebar>
);
}
@@ -9,12 +9,13 @@ import { TeamPreference } from "@shared/types";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import { openDocumentInsights } from "~/actions/definitions/documents";
import DocumentMeta from "~/components/DocumentMeta";
import DocumentMeta, { Separator } from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import breakpoint from "styled-components-breakpoint";
import { documentPath } from "~/utils/routeHelpers";
import NudeButton from "~/components/NudeButton";
@@ -46,7 +47,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
<Meta document={document} revision={revision} to={to} replace {...rest}>
{commentingEnabled && can.comment && (
<>
&nbsp;&nbsp;
<Separator />
<CommentLink
to={{
pathname: documentPath(document),
@@ -66,7 +67,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
!document.isDraft &&
!document.isTemplate ? (
<Wrapper>
&nbsp;&nbsp;
<Separator />
<InsightsButton action={openDocumentInsights}>
{t("Viewed by")}{" "}
{onlyYou
@@ -108,6 +109,16 @@ export const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
user-select: none;
z-index: 1;
${breakpoint("mobile", "tablet")`
flex-direction: column;
align-items: flex-start;
line-height: 1.6;
${Separator} {
display: none;
}
`}
a {
color: inherit;
cursor: var(--pointer);
+2 -2
View File
@@ -1,4 +1,3 @@
import last from "lodash/last";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -33,6 +32,7 @@ import { decodeURIComponentSafe } from "~/utils/urls";
import MultiplayerEditor from "./AsyncMultiplayerEditor";
import DocumentMeta from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle";
import first from "lodash/first";
const extensions = withUIExtensions(withComments(richExtensions));
@@ -80,7 +80,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const can = usePolicy(document);
const commentingEnabled = !!team?.getPreference(TeamPreference.Commenting);
const iconColor = document.color ?? (last(colorPalette) as string);
const iconColor = document.color ?? (first(colorPalette) as string);
const childRef = React.useRef<HTMLDivElement>(null);
const focusAtStart = React.useCallback(() => {
if (ref.current) {
@@ -15,6 +15,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
import useMobile from "~/hooks/useMobile";
const DocumentEvents = [
"documents.publish",
@@ -37,6 +38,7 @@ function History() {
const document = documents.get(match.params.documentSlug);
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
const [eventsOffset, setEventsOffset] = React.useState(0);
const isMobile = useMobile();
const fetchHistory = React.useCallback(async () => {
if (!document) {
@@ -125,6 +127,10 @@ function History() {
}, [revisions, document, revisionEvents, nonRevisionEvents]);
const onCloseHistory = React.useCallback(() => {
if (isMobile) {
// Allow closing the history drawer on mobile to view revision content
return;
}
if (document) {
history.push({
pathname: documentPath(document),
@@ -3,15 +3,19 @@ import { BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s, ellipsis } from "@shared/styles";
import { s, ellipsis } from "@shared/styles";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import Tooltip from "~/components/Tooltip";
import useMobile from "~/hooks/useMobile";
import { draggableOnDesktop } from "~/styles";
import { fadeIn } from "~/styles/animations";
import RightSidebar from "~/components/Sidebar/Right";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
/* The title of the sidebar */
@@ -19,7 +23,7 @@ type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
/* The content of the sidebar */
children: React.ReactNode;
/* Called when the sidebar is closed */
onClose: React.MouseEventHandler;
onClose: () => void;
/* Whether the sidebar should be scrollable */
scrollable?: boolean;
};
@@ -28,8 +32,23 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
const { t } = useTranslation();
const isMobile = useMobile();
return (
<>
const content = scrollable ? (
<Scrollable hiddenScrollbars topShadow>
{children}
</Scrollable>
) : (
children
);
return isMobile ? (
<Drawer onClose={onClose} defaultOpen>
<DrawerContent>
<DrawerTitle>{title}</DrawerTitle>
{content}
</DrawerContent>
</Drawer>
) : (
<RightSidebar>
<Header>
<Title>{title}</Title>
<Tooltip content={t("Close")} shortcut="Esc">
@@ -41,35 +60,11 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
/>
</Tooltip>
</Header>
{scrollable ? (
<Scrollable hiddenScrollbars topShadow>
{children}
</Scrollable>
) : (
children
)}
{isMobile && (
<Portal>
<Backdrop onClick={onClose} />
</Portal>
)}
</>
{content}
</RightSidebar>
);
}
const Backdrop = styled.a`
animation: ${fadeIn} 250ms ease-in-out;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
cursor: default;
z-index: ${depths.mobileSidebar - 1};
background: ${s("backdrop")};
`;
const ForwardIcon = styled(BackIcon)`
transform: rotate(180deg);
flex-shrink: 0;
+14 -8
View File
@@ -59,11 +59,14 @@ export default class GroupUsersStore extends Store<GroupUser> {
permission,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
return runInAction(`GroupUsersStore#create`, () => {
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
});
}
@action
@@ -96,11 +99,14 @@ export default class GroupUsersStore extends Store<GroupUser> {
permission,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
return runInAction(`GroupUsersStore#update`, () => {
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
});
}
@action
+1 -1
View File
@@ -55,7 +55,7 @@ export function redirectTo(url: string) {
export const isAllowedLoginRedirect = (input: string) => {
const path = input.split("?")[0].split("#")[0];
return (
!["/", "/create", "/home", "/logout"].includes(path) &&
!["/", "/create", "/home", "/logout", "/desktop-redirect"].includes(path) &&
!path.startsWith("/auth/") &&
!path.startsWith("/s/")
);
+15 -15
View File
@@ -51,16 +51,16 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/lib-storage": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",
"@aws-sdk/signature-v4-crt": "^3.879.0",
"@babel/core": "^7.28.3",
"@aws-sdk/client-s3": "3.893.0",
"@aws-sdk/lib-storage": "3.893.0",
"@aws-sdk/s3-presigned-post": "3.893.0",
"@aws-sdk/s3-request-presigner": "3.893.0",
"@aws-sdk/signature-v4-crt": "^3.893.0",
"@babel/core": "^7.28.4",
"@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-regenerator": "^7.28.3",
"@babel/plugin-transform-regenerator": "^7.28.4",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
@@ -73,9 +73,9 @@
"@dotenvx/dotenvx": "^1.49.0",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.5",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/fontawesome-svg-core": "^7.0.1",
"@fortawesome/free-brands-svg-icons": "^7.0.1",
"@fortawesome/free-solid-svg-icons": "^7.0.1",
"@fortawesome/react-fontawesome": "^0.2.6",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@hocuspocus/extension-redis": "1.1.2",
@@ -100,6 +100,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toolbar": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.2",
"@sentry/node": "^7.120.4",
@@ -148,7 +149,7 @@
"i18next-fs-backend": "^2.6.0",
"i18next-http-backend": "^2.7.3",
"invariant": "^2.2.4",
"ioredis": "^5.6.0",
"ioredis": "^5.7.0",
"is-printable-key-event": "^1.0.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
@@ -227,7 +228,6 @@
"react-virtualized-auto-sizer": "^1.0.26",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.11",
"reakit": "^1.3.11",
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.2.2",
"refractor": "^3.6.0",
@@ -315,7 +315,7 @@
"@types/node": "20.17.30",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
"@types/passport-oauth2": "^1.8.0",
"@types/pluralize": "^0.0.33",
"@types/png-chunks-extract": "^1.0.2",
"@types/proxy-from-env": "^1.0.4",
@@ -327,7 +327,7 @@
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-virtualized-auto-sizer": "^1.0.8",
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.21",
"@types/redis-info": "^3.0.3",
@@ -381,6 +381,6 @@
"qs": "6.9.7",
"prismjs": "1.30.0"
},
"version": "0.87.3",
"version": "0.87.4",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1003 B

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 876 B

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 B

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1017 B

After

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 B

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 922 B

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 753 B

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 B

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 B

After

Width:  |  Height:  |  Size: 631 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 B

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 759 B

After

Width:  |  Height:  |  Size: 673 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1010 B

@@ -106,4 +106,70 @@ describe("documentDuplicator", () => {
expect(response[0].color).toEqual(original.color);
expect(response[0].publishedAt).toBeNull();
});
it("should set originalDocumentId in sourceMetadata when duplicating", async () => {
const user = await buildUser();
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
sourceMetadata: { fileName: "test.md", externalId: "ext123" },
});
const response = await sequelize.transaction((transaction) =>
documentDuplicator({
document: original,
collection: original.collection,
user,
ctx: createContext({ user, transaction }),
})
);
expect(response).toHaveLength(1);
expect(response[0].sourceMetadata).toEqual({
fileName: "test.md",
externalId: "ext123",
originalDocumentId: original.id,
});
});
it("should set originalDocumentId for child documents when duplicating recursively", async () => {
const user = await buildUser();
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const childDocument = await buildDocument({
userId: user.id,
teamId: user.teamId,
parentDocumentId: original.id,
collection: original.collection,
sourceMetadata: { fileName: "child.md" },
});
const response = await sequelize.transaction((transaction) =>
documentDuplicator({
document: original,
collection: original.collection,
user,
recursive: true,
ctx: createContext({ user, transaction }),
})
);
expect(response).toHaveLength(2);
// Check parent document
const duplicatedParent = response.find((doc) => !doc.parentDocumentId);
expect(duplicatedParent?.sourceMetadata?.originalDocumentId).toEqual(
original.id
);
// Check child document
const duplicatedChild = response.find((doc) => doc.parentDocumentId);
expect(duplicatedChild?.sourceMetadata?.originalDocumentId).toEqual(
childDocument.id
);
expect(duplicatedChild?.sourceMetadata?.fileName).toEqual("child.md");
});
});
+8
View File
@@ -52,6 +52,10 @@ export default async function documentDuplicator({
DocumentHelper.toProsemirror(document),
["comment"]
),
sourceMetadata: {
...document.sourceMetadata,
originalDocumentId: document.id,
},
...sharedProperties,
});
@@ -85,6 +89,10 @@ export default async function documentDuplicator({
DocumentHelper.toProsemirror(childDocument),
["comment"]
),
sourceMetadata: {
...childDocument.sourceMetadata,
originalDocumentId: childDocument.id,
},
...sharedProperties,
});
+54 -1
View File
@@ -1,8 +1,9 @@
import { faker } from "@faker-js/faker";
import { UserRole } from "@shared/types";
import { buildUser } from "@server/test/factories";
import { buildTeam, buildUser } from "@server/test/factories";
import userInviter from "./userInviter";
import { withAPIContext } from "@server/test/support";
import { TeamDomain } from "@server/models";
describe("userInviter", () => {
it("should return sent invites", async () => {
@@ -37,6 +38,58 @@ describe("userInviter", () => {
expect(response.sent.length).toEqual(0);
});
it("should error on non allowed domains", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await TeamDomain.create({
teamId: team.id,
name: faker.internet.domainName(),
createdById: user.id,
});
await withAPIContext(user, (ctx) =>
expect(
userInviter(ctx, {
invites: [
{
role: UserRole.Member,
email: "test@example.com",
name: "Test",
},
],
})
).rejects.toThrow("The domain is not allowed for this workspace")
);
});
it("should allow invites for allowed domains", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const allowedDomain = "google.com";
await TeamDomain.create({
teamId: team.id,
name: allowedDomain,
createdById: user.id,
});
const response = await withAPIContext(user, (ctx) =>
userInviter(ctx, {
invites: [
{
role: UserRole.Member,
email: `test@${allowedDomain}`,
name: "Test User",
},
],
})
);
expect(response.sent.length).toEqual(1);
expect(response.sent[0].email).toEqual(`test@${allowedDomain}`);
});
it("should filter obviously bunk emails", async () => {
const user = await buildUser();
const response = await withAPIContext(user, (ctx) =>
+8
View File
@@ -6,6 +6,7 @@ import Logger from "@server/logging/Logger";
import { User, Team } from "@server/models";
import { UserFlag } from "@server/models/User";
import { APIContext } from "@server/types";
import { DomainNotAllowedError } from "@server/errors";
export type Invite = {
name: string;
@@ -41,6 +42,13 @@ export default async function userInviter(
);
// filter out any existing users in the system
const emails = normalizedInvites.map((invite) => invite.email);
for (const email of emails) {
if (!(await team.isDomainAllowed(email))) {
throw DomainNotAllowedError();
}
}
const existingUsers = await User.findAll({
where: {
teamId: user.teamId,
+23
View File
@@ -0,0 +1,23 @@
const shared = {
use_env_variable: process.env.DATABASE_URL ? "DATABASE_URL" : undefined,
dialect: "postgres",
host: process.env.DATABASE_HOST,
port: process.env.DATABASE_PORT || 5432,
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD || undefined,
database: process.env.DATABASE_NAME,
};
module.exports = {
development: shared,
test: shared,
"production-ssl-disabled": shared,
production: {
...shared,
dialectOptions: {
ssl: {
rejectUnauthorized: false,
},
},
},
};
-23
View File
@@ -1,23 +0,0 @@
{
"development": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres"
},
"test": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres"
},
"production": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres",
"dialectOptions": {
"ssl": {
"rejectUnauthorized": false
}
}
},
"production-ssl-disabled": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres"
}
}
@@ -0,0 +1,36 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// Drop the existing foreign key constraint
await queryInterface.sequelize.query(
`ALTER TABLE "shares" DROP CONSTRAINT "shares_collectionId_fkey"`
);
// Add the foreign key constraint with CASCADE delete
await queryInterface.sequelize.query(`
ALTER TABLE "shares"
ADD CONSTRAINT "shares_collectionId_fkey"
FOREIGN KEY("collectionId")
REFERENCES "collections" ("id")
ON DELETE CASCADE
`);
},
async down(queryInterface, Sequelize) {
// Drop the cascade constraint
await queryInterface.sequelize.query(
`ALTER TABLE "shares" DROP CONSTRAINT "shares_collectionId_fkey"`
);
// Add back the original constraint without cascade
await queryInterface.sequelize.query(`
ALTER TABLE "shares"
ADD CONSTRAINT "shares_collectionId_fkey"
FOREIGN KEY("collectionId")
REFERENCES "collections" ("id")
ON DELETE NO ACTION
`);
},
};
+9 -1
View File
@@ -26,6 +26,7 @@ import ParanoidModel from "./base/ParanoidModel";
import Encrypted from "./decorators/Encrypted";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
import { randomString } from "@shared/random";
@DefaultScope(() => ({
include: [
@@ -98,7 +99,14 @@ class WebhookSubscription extends ParanoidModel<
}
}
// methods
// instance methods
/**
* Rotate the secret value. Does not persist to database.
*/
public rotateSecret() {
this.secret = `ol_whs_${randomString(32)}`;
}
/**
* Disables the webhook subscription
+10 -1
View File
@@ -208,7 +208,7 @@ allow(User, "delete", Document, (actor, document) =>
)
);
allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
allow(User, "restore", Document, (actor, document) =>
and(
isTeamModel(actor, document),
!actor.isGuest,
@@ -229,6 +229,15 @@ allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
)
);
allow(User, "permanentDelete", Document, (actor, document) =>
and(
isTeamModel(actor, document),
!actor.isGuest,
!!document?.isDeleted,
isTeamAdmin(actor, document)
)
);
allow(User, "archive", Document, (actor, document) =>
and(
!document?.template,
+1
View File
@@ -99,6 +99,7 @@ async function presentDocument(
importType: source?.format,
createdByName: document.sourceMetadata.createdByName,
fileName: document.sourceMetadata?.fileName,
originalDocumentId: document.sourceMetadata?.originalDocumentId,
}
: undefined;
}

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