Compare commits

..

142 Commits

Author SHA1 Message Date
Tom Moor bab2729669 Add optional chaining to avatarUrl check 2025-04-06 15:46:48 -07:00
Tom Moor 90f9721b40 Remove domain parameter and update avatarUrl check 2025-04-06 15:28:34 -07:00
codegen-sh[bot] dc474573c6 Remove avatars.ts, avatars.test.ts and update teamCreator.ts to remove generateAvatarUrl usage 2025-04-06 22:26:59 +00:00
codegen-sh[bot] a3910ce6d1 #8873: Remove usage of generateAvatarUrl and logo.clearbit.com API 2025-04-06 22:21:25 +00:00
Tom Moor f9476770ce fix: Collaboration server inaccurately counts connections (#8886)
* fix: Collaboration server inaccurately counts connections

* Add integration test

* docs
2025-04-06 21:55:10 +00:00
Hemachandar 2e018e74b8 Log fields that cause UniqueConstraintError in ImportsProcessor (#8887) 2025-04-06 11:10:59 -07:00
codegen-sh[bot] a11ab56117 Cleanup the old Notion importer (#8832)
* Cleanup the old Notion importer

* Fix Notion importer cleanup PR based on feedback

* Restore Notion format references for backward compatibility

* Remove Notion import fixtures

* translations

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-04-06 09:04:35 -07:00
codegen-sh[bot] 66e4ec32ed Fix: Handle Notion database not found errors gracefully (#8860)
* Fix: Handle Notion database not found errors gracefully

* Fix: Use Logger.warn instead of console.log in Notion import task

* Applied automatic fixes

* Touch to trigger actions

* Fix: Implement additional improvements for Notion import error handling

* Applied automatic fixes

* Change to trigger CI

* Fix TypeScript error: Add type assertion for filtered parsedPages

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Codegen <codegen@example.com>
2025-04-06 09:04:23 -07:00
codegen-sh[bot] bde9d5fbf4 Move post-login redirect logic to AuthenticatedLayout (#8864)
* Move post-login redirect logic to AuthenticatedLayout

* Applied automatic fixes

* fix typography

* Restore Login/index.tsx

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-04-01 17:37:22 -07:00
Tom Moor 70bb878a8c fix: Missing transaction in save causing deadlocks (#8866) 2025-04-01 12:46:06 +00:00
Hemachandar 4237377d47 fix: Skip sequelize hooks when creating user membership in collections.update (#8849) 2025-04-01 04:43:31 -07:00
dependabot[bot] a30f6b717b chore(deps): bump the aws group with 5 updates (#8857)
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.774.0` | `3.777.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.774.0` | `3.777.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.774.0` | `3.777.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.774.0` | `3.777.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.774.0` | `3.775.0` |


Updates `@aws-sdk/client-s3` from 3.774.0 to 3.777.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.777.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.774.0 to 3.777.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.777.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.774.0 to 3.777.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.777.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.774.0 to 3.777.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.777.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.774.0 to 3.775.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.775.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 04:43:22 -07:00
dependabot[bot] 1edc23c5ae chore(deps): bump vite from 5.4.15 to 5.4.16 (#8861)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.15 to 5.4.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.16/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.16/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-31 22:08:11 +00:00
dependabot[bot] ff6ec3a5b8 chore(deps): bump prosemirror-markdown from 1.13.1 to 1.13.2 (#8855)
Bumps [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown) from 1.13.1 to 1.13.2.
- [Changelog](https://github.com/ProseMirror/prosemirror-markdown/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-markdown/compare/1.13.1...1.13.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-31 15:04:53 -07:00
dependabot[bot] 52c2729490 chore(deps-dev): bump @relative-ci/agent from 4.2.14 to 4.3.0 (#8854)
Bumps [@relative-ci/agent](https://github.com/relative-ci/agent) from 4.2.14 to 4.3.0.
- [Release notes](https://github.com/relative-ci/agent/releases)
- [Commits](https://github.com/relative-ci/agent/compare/v4.2.14...v4.3.0)

---
updated-dependencies:
- dependency-name: "@relative-ci/agent"
  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-03-31 14:45:36 -07:00
dependabot[bot] 82f4281a02 chore(deps): bump @tanstack/react-virtual from 3.11.3 to 3.13.6 (#8858)
Bumps [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) from 3.11.3 to 3.13.6.
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.13.6/packages/react-virtual)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-31 14:45:13 -07:00
dependabot[bot] 12b6e30e3a chore(deps): bump prosemirror-model from 1.24.1 to 1.25.0 (#8856)
* chore(deps): bump prosemirror-model from 1.24.1 to 1.25.0

Bumps [prosemirror-model](https://github.com/prosemirror/prosemirror-model) from 1.24.1 to 1.25.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-model/compare/1.24.1...1.25.0)

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

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

* Update Code mark to use the new `code` property

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-31 14:45:02 -07:00
Tom Moor 567ca7e3f1 fix: Table columns sometimes lost in copy paste (#8845)
closes #8841
2025-03-30 20:06:22 -07:00
codegen-sh[bot] 97c3ea7da8 Allow inline code to be bolded and italicized (#8843)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-30 14:44:21 -07:00
Tom Moor 4af2b032dd fix: New comments are measured incorrectly (#8838)
* fix: New comments are measured incorrectly

* Remove defaultRect so we can always return a DOMRect
2025-03-30 11:48:51 -07:00
Tom Moor c52d9a850d fix: Paste partially over code prevents pasting PM nodes (#8836)
* fix: Paste over any inline code prevents pasting nodes
closes #8825

* Add inclusive logic for isNodeActive
2025-03-30 11:48:44 -07:00
Tom Moor 588e5bc17f fix: Reduce gap between at symbol and name in user mentions (#8839) 2025-03-30 17:26:35 +00:00
Tom Moor a2bd0edd82 chore: Missing react key in SuggestionMenu (#8837) 2025-03-30 14:36:15 +00:00
Tom Moor ca0f0638c9 fix: Handle deleted user in NotificationHelper (#8835) 2025-03-29 19:11:04 -07:00
Tom Moor f13e6a3691 fix: Show @ symbol on mentions in email snippets (#8833) 2025-03-30 00:26:18 +00:00
Hemachandar dcb7b86df8 Store import error in DB (#8811) 2025-03-29 06:08:07 -07:00
Hemachandar 45c6e72c6d Manage collection subscriptions when user (or) group is removed from a collection (#8821)
* Manage collection subscriptions when user (or) group is removed from a collection

* rename collection task

* rename document task

* remove unnecessary actor filter
2025-03-29 06:07:57 -07:00
codegen-sh[bot] a51456deb3 Add missing JSDoc to shared components (#8829)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-28 14:27:30 -07:00
Aditya Sharma 3ffe7e7671 fix: conversion b/w checkbox & other list types (#8828) 2025-03-28 14:19:12 -07:00
Hemachandar a7fe6c9af3 Include non-deleted imports for cleanup (#8822) 2025-03-28 05:46:36 -07:00
codegen-sh[bot] 52c673261b Add JSDoc to hooks in app/hooks directory (#8819)
* Add JSDoc to hooks in app/hooks directory

* lint

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-28 02:37:32 +00:00
Tom Moor 60c0a53a1f chore: Change lint rule to trigger on actor rather than branch name (#8820) 2025-03-28 02:24:00 +00:00
Tom Moor 66fae19034 fix: Improve performance of notification queries (#8809)
* Remove onlySubscribers

* refactor

* perf
2025-03-27 19:10:32 -07:00
codegen-sh[bot] 37ea6bb92b Add JSDoc comments to AvatarWithPresence component (#8817)
* Add JSDoc comments to AvatarWithPresence component

* lint

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-03-27 19:07:24 -07:00
Tom Moor 762816adbc Update lint.yml (#8818) 2025-03-28 01:58:24 +00:00
Tom Moor d1b24b15d5 chore: Attempt auto-lint of Codegen PR's (#8816) 2025-03-28 01:42:28 +00:00
Hemachandar 877b7ad0df fix: Handle index collision when creating a collection (#8803)
* fix: Handle index collision when creating a collection

* move to sequelize hooks

* index maxLen parity between api and model

* remove beforeUpdate hook

* use common indexLen in model

* beforeUpdate hook..

* test
2025-03-27 02:50:40 -07:00
Tom Moor e98d931aaa Remove maintainers from probot behavior (#8808) 2025-03-26 23:37:59 +00:00
Tom Moor ba7d102a72 perf: Avoid querying all users in team for common notification types (#8806) 2025-03-26 16:19:45 -07:00
codegen-sh[bot] ab1f00e919 fix: handle missing user error during Notion import (#8801)
* fix: handle missing user error during Notion import

* lint

* typesafe check

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: hmacr <hmac.devo@gmail.com>
2025-03-26 07:46:53 -07:00
dependabot[bot] 34cb31ff43 chore(deps): bump ioredis from 5.4.1 to 5.6.0 (#8789)
Bumps [ioredis](https://github.com/luin/ioredis) from 5.4.1 to 5.6.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.4.1...v5.6.0)

---
updated-dependencies:
- dependency-name: ioredis
  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-03-26 05:48:58 -07:00
codegen-sh[bot] aac95c2b2e Add SMTP_SERVICE environment variable for well-known services (#8781)
* Add SMTP_SERVICE environment variable for well-known services

* Fix PR #8777: Restore code in teams.ts and users.ts

* The rest of the work

* fix validation

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-26 05:48:47 -07:00
dependabot[bot] 0dd6ef5196 chore(deps): bump vite from 5.4.14 to 5.4.15 (#8798)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.14 to 5.4.15.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.15/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.15/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-26 01:35:02 +00:00
dependabot[bot] 5cd11002d1 chore(deps): bump the aws group with 5 updates (#8788)
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.772.0` | `3.774.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.772.0` | `3.774.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.772.0` | `3.774.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.772.0` | `3.774.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.758.0` | `3.774.0` |


Updates `@aws-sdk/client-s3` from 3.772.0 to 3.774.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.774.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.772.0 to 3.774.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.774.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.772.0 to 3.774.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.774.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.772.0 to 3.774.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.774.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.758.0 to 3.774.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.774.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 18:29:29 -07:00
dependabot[bot] 5334f7ae08 chore(deps-dev): bump @types/node from 20.17.16 to 20.17.27 (#8790)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.17.16 to 20.17.27.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 18:29:18 -07:00
dependabot[bot] df1de2b822 chore(deps): bump zod from 3.23.8 to 3.24.2 (#8791)
Bumps [zod](https://github.com/colinhacks/zod) from 3.23.8 to 3.24.2.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Changelog](https://github.com/colinhacks/zod/blob/main/CHANGELOG.md)
- [Commits](https://github.com/colinhacks/zod/compare/v3.23.8...v3.24.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 18:29:08 -07:00
dependabot[bot] deb93ef767 chore(deps): bump pg from 8.12.0 to 8.14.1 (#8792)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.12.0 to 8.14.1.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.14.1/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  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-03-25 18:28:59 -07:00
Tom Moor 5bef4c4b55 fix: typeerror cannot read properties of undefined reading lang (#8794)
* fix: Access of undefined with invalid code lang
closes #8793

* test
2025-03-25 12:31:53 +00:00
Tom Moor 72bff1ec8a Revert "Change @aws-sdk dependency update frequency from weekly to monthly (#…" (#8787)
This reverts commit 323c5f5978.
2025-03-25 11:58:21 +00:00
Tom Moor c12b257098 fix: Use configured proxy for OIDC server-to-server requests (#8776) 2025-03-25 04:31:16 -07:00
Hemachandar f6da244c33 fix: Handle empty text blocks from Notion response (#8785) 2025-03-25 04:31:06 -07:00
Tom Moor ab55e0bed9 feat: Add XML as code formatting option (#8767)
Refactor to achieve this
2025-03-24 14:58:05 -07:00
dependabot[bot] 84ae9a2c31 chore(deps): bump datadog-metrics from 0.11.2 to 0.12.1 (#8773)
* chore(deps): bump datadog-metrics from 0.11.2 to 0.12.1

Bumps [datadog-metrics](https://github.com/dbader/node-datadog-metrics) from 0.11.2 to 0.12.1.
- [Release notes](https://github.com/dbader/node-datadog-metrics/releases)
- [Commits](https://github.com/dbader/node-datadog-metrics/compare/v0.11.2...v0.12.1)

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

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

* fix: flush now returns a promise

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-24 21:38:54 +00:00
Hemachandar 5c4eb32c26 fix: Release redis lock only when it hasn't expired (#8765)
* fix: Suppress redlock release errors

* release only when lock hasn't expired
2025-03-24 14:37:36 -07:00
dependabot[bot] 10b8f11e0b chore(deps): bump the babel group with 4 updates (#8769)
Bumps the babel group with 4 updates: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core), [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator), [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) and [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript).


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

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

Updates `@babel/cli` from 7.26.4 to 7.27.0
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-cli)

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

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 14:27:21 -07:00
dependabot[bot] 0a4c3bd633 chore(deps): bump the aws group with 4 updates (#8770)
Bumps the aws group with 4 updates: [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3), [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage), [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) and [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner).


Updates `@aws-sdk/client-s3` from 3.758.0 to 3.772.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.772.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.758.0 to 3.772.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.772.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.758.0 to 3.772.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.772.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.758.0 to 3.772.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.772.0/packages/s3-request-presigner)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 14:27:13 -07:00
dependabot[bot] 580cf52fd3 chore(deps-dev): bump rollup-plugin-webpack-stats from 2.0.1 to 2.0.3 (#8771)
Bumps [rollup-plugin-webpack-stats](https://github.com/relative-ci/rollup-plugin-webpack-stats) from 2.0.1 to 2.0.3.
- [Release notes](https://github.com/relative-ci/rollup-plugin-webpack-stats/releases)
- [Commits](https://github.com/relative-ci/rollup-plugin-webpack-stats/compare/v2.0.1...v2.0.3)

---
updated-dependencies:
- dependency-name: rollup-plugin-webpack-stats
  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-03-24 14:27:06 -07:00
dependabot[bot] ee1fd65a19 chore(deps): bump prosemirror-commands from 1.6.2 to 1.7.0 (#8772)
Bumps [prosemirror-commands](https://github.com/prosemirror/prosemirror-commands) from 1.6.2 to 1.7.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-commands/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-commands/compare/1.6.2...1.7.0)

---
updated-dependencies:
- dependency-name: prosemirror-commands
  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-03-24 14:26:59 -07:00
codegen-sh[bot] 323c5f5978 Change @aws-sdk dependency update frequency from weekly to monthly (#8774)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-24 14:24:18 -07:00
Tom Moor bdb34a202c fix: Disable Notion import when env is not available (#8761) 2025-03-24 02:52:54 +00:00
Tom Moor 40278b2d9a fix: notifications.pixel requests hang (#8760)
tests
2025-03-24 00:52:54 +00:00
Tom Moor a69ef1f3c9 quick: Remove expired temporary AWS keys from fixture data (#8755)
* fix: Remove temporary AWS keys causing false positive alerts

* Previously missed PR feedback

* snap
2025-03-23 19:31:38 +00:00
Hemachandar 6e98568e5b API importer for Notion (#8710) 2025-03-23 12:19:13 -07:00
Tom Moor 8b65ad3cfa chore: Move notification event writing to model layer (#8754)
* Move notification event writing to model layer
fix: Bulk notification action does not reflect on other clients

* Add missing locks

* fixes
2025-03-23 11:59:19 -07:00
Tom Moor 533a14369c fix: Do not wait for connections at end of test suites (#8752) 2025-03-22 20:46:36 -07:00
Tom Moor 0ec6440506 Prevent outdated clients from connecting to collaboration server (#8751)
* Move editor version check to collaboration server connection

* connected -> onConnect

* docs

* Remove hardcoded event codes
2025-03-22 14:35:45 -07:00
Tom Moor 6fde025ce4 Revert "Send editor version down websocket and force reload (#8582)" (#8750)
This reverts commit 13f45e1a1c.
2025-03-22 20:06:54 +00:00
Tom Moor 18bbe6ecf6 fix: Direct link to heading lost when pasting (#8749) 2025-03-22 10:02:09 -07:00
Tom Moor a48f6c7a85 fix: Unsubscribe link for collection subscriptions (#8734)
* fix: Cannot unsubscribe from collection subscriptions via email token

* tests

* Separate redirect for pass through

* Delete both subscriptions

* Test draft documents
2025-03-22 08:22:20 -07:00
Tom Moor ec9f45f310 fix: Allow team admin to manage permissions on any document they have access to (#8746) 2025-03-22 08:22:09 -07:00
Tom Moor dd053c4152 fix: Allow dash,emdash,underscore in mention search (#8747)
* fix: Allow dash,emdash,underscore in mention search

* fix: Highlight color on secondary accent menu items
2025-03-22 08:22:01 -07:00
Tom Moor 5565034486 Revert "Double test timeout (#8696)" (#8738)
This reverts commit 7c41c1360b.
2025-03-21 12:38:28 +00:00
Tom Moor 42cfac97aa fix: Add prevention of auto-following unsubscribe links in emails (#8735) 2025-03-21 04:57:26 -07:00
Tom Moor f369c2f8bf Refactor logic for validating authentication tokens (#8727)
* Remove use of Promise.any

* Restore retry on all invalid
2025-03-20 20:49:47 -07:00
Tom Moor 08f91aa60c fix: Error rendering read only editor with paragraph without text content (#8730) 2025-03-20 13:46:53 +00:00
Tom Moor 0fe50c179c fix: "unknown" users visible in users table (#8726)
* fix: 'Unknown' users appearing in members table

* snapshots

* refactor
2025-03-19 21:00:04 -07:00
Josiah "Rebase" Roberts ae249f720d Allow middle click (#8725) 2025-03-19 16:27:47 -07:00
Tom Moor 0a9e76f600 fix: Mentions of current user not highlighted (#8718) 2025-03-18 18:08:49 -07:00
Tom Moor 912b9159f0 fix: Add missing users.delete event handling (#8715) 2025-03-18 15:27:27 -07:00
Tom Moor 307f4a1351 fix: Write lastValidatedAt when accessToken changes (#8716) 2025-03-18 15:27:19 -07:00
Hemachandar 021a286d99 Migrate language, theme and user-role input-select to Radix (#8711)
* Migrate language, theme and user-role input-select to Radix

* use theme
2025-03-17 20:37:40 -07:00
Tom Moor 6869d4cb02 perf: Add fast read-only editor (#8704)
* stash

* tests

* sp
2025-03-16 07:12:16 -07:00
Hemachandar c311ee915e Radix input select component (#8541)
* radix primitive and base input select component

* port toc position select menu

* fix render side

* restyle drawer title

* max-height for select content

* rename primitive

* review

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-15 18:14:46 -07:00
Tom Moor b195b0e3b5 Add 'No access to doc' screen (#8702) 2025-03-15 15:06:12 -07:00
Tom Moor c783ccad1e Move search term to query string (#8701) 2025-03-15 14:19:11 -07:00
Tom Moor 619c56102c chore: Always log outgoing emails in development (#8700) 2025-03-15 18:25:14 +00:00
Tom Moor 7c41c1360b Double test timeout (#8696) 2025-03-14 19:51:06 -07:00
Tom Moor f3a1b47ccf fix: Styling of selected event list item (#8685) 2025-03-13 03:43:18 +00:00
Tom Moor af234465f0 fix: dd-trace upgrade causes errors/high memory consumption (#8684) 2025-03-13 01:48:30 +00:00
Tom Moor 5a1aeed989 fix: API middleware wrapper triggers on JSZip stream (#8683) 2025-03-13 00:50:41 +00:00
Tom Moor 6ea4ce72ec chore: Improve CSV output sanitization (#8682) 2025-03-13 00:23:48 +00:00
dependabot[bot] 8041d9c3bd chore(deps): bump prosemirror-tables from 1.4.0 to 1.6.4 (#8557)
Bumps [prosemirror-tables](https://github.com/prosemirror/prosemirror-tables) from 1.4.0 to 1.6.4.
- [Release notes](https://github.com/prosemirror/prosemirror-tables/releases)
- [Changelog](https://github.com/ProseMirror/prosemirror-tables/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-tables/compare/v1.4.0...v1.6.4)

---
updated-dependencies:
- dependency-name: prosemirror-tables
  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-03-12 15:47:45 -07:00
Tom Moor 516d14fe27 fix: Potential unsafe content-type check (#8673)
* fix: Potential bypass of content-type check

* Include extra available chars
2025-03-12 12:39:41 +00:00
dependabot[bot] 70268a73df chore(deps): bump @babel/runtime from 7.26.9 to 7.26.10 (#8672)
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.26.9 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 20:09:08 -07:00
dependabot[bot] 148be1025f chore(deps): bump @babel/helpers from 7.26.9 to 7.26.10 (#8671)
Bumps [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) from 7.26.9 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-helpers)

---
updated-dependencies:
- dependency-name: "@babel/helpers"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 20:08:44 -07:00
Tom Moor 2a17ac1908 chore: Upgrade prismjs (#8670) 2025-03-12 02:36:31 +00:00
Tom Moor a70a67235d fix: First item in list must be a paragraph (#8632)
closes #8611

closes #8216
2025-03-11 18:56:17 -07:00
Tom Moor ed5bb8f8d9 fix: Inline code converts to block on paste from remote source (#8669) 2025-03-11 18:55:59 -07:00
dependabot[bot] a7731d9963 chore(deps-dev): bump discord-api-types from 0.37.102 to 0.37.119 (#8659)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.37.102 to 0.37.119.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.37.102...0.37.119)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:56 -07:00
dependabot[bot] 6f5e0b70bc chore(deps-dev): bump terser from 5.37.0 to 5.39.0 (#8660)
Bumps [terser](https://github.com/terser/terser) from 5.37.0 to 5.39.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.37.0...v5.39.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:46 -07:00
dependabot[bot] 856467fa0c chore(deps): bump prosemirror-view from 1.37.1 to 1.38.1 (#8661)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.37.1 to 1.38.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.37.1...1.38.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:32 -07:00
dependabot[bot] 280ec17f63 chore(deps): bump @types/form-data from 2.5.0 to 2.5.2 (#8662)
Bumps [@types/form-data](https://github.com/DefinitelyTyped/DefinitelyTyped) from 2.5.0 to 2.5.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits)

---
updated-dependencies:
- dependency-name: "@types/form-data"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:05 -07:00
Tom Moor 84b48167cb chore: Bump @koa/bull-board (#8655) 2025-03-09 01:39:57 +00:00
Tom Moor c6f90b7647 chore: Bump dd-trace (#8654) 2025-03-09 01:29:17 +00:00
dependabot[bot] 42865b64d6 chore(deps): bump axios from 1.7.9 to 1.8.2 (#8653)
Bumps [axios](https://github.com/axios/axios) from 1.7.9 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.9...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-08 17:18:31 -08:00
Tom Moor e5b5cbaab7 Revert "chore: Upgrade path-to-regexp (#8636)" (#8652)
This reverts commit 58c4a486f7.
2025-03-08 07:18:10 -08:00
Tom Moor 463398e2c7 tom/misc-fixes (#8650) 2025-03-08 03:42:19 +00:00
Tom Moor 98c9af53c4 fix: recent searches appearing over dropdown options on search page (#8640)
* fix: Various UX issues with search filters

* Tighted search filters display
2025-03-06 05:27:57 -08:00
Tom Moor f0864b5876 fix: Add more tldraw url support (#8638) 2025-03-06 03:02:40 +00:00
Tom Moor c89535426b chore: Upgrade i18next-parser (#8637) 2025-03-06 01:40:28 +00:00
Tom Moor 58c4a486f7 chore: Upgrade path-to-regexp (#8636)
* chore: Upgrade koa-router

* chore: Upgrade dd-trace
2025-03-05 13:36:30 -08:00
Hemachandar d5462a92c8 fix: Skip unsubscribing when user has access to document (#8631)
* fix: Skip unsubscribing when user has access to document

* better checks
2025-03-04 19:26:13 -08:00
Hemachandar 7a90a909b3 Prevent duplicate emails when user has existing access to a document. (#8263)
* check user has higher access

* membershipId column

* handle document shared email

* fix and cleanup

* tests

* jsdoc

* event changeset

* check collection permission

* change date in migration filename

* review

* rename migration filename to today

* required group, jsdoc
2025-03-04 17:56:44 -08:00
Hemachandar 189ad30138 fix: Skip auto creating subscriptions when user/group is added to a document (#8630) 2025-03-04 16:58:20 -08:00
Hemachandar feb412b1fb fix: Filter archived collections in start view selection (#8629) 2025-03-04 15:26:50 -08:00
YouLL d551a1a10b feat: collection mentions (#8529)
* feat: init collection mention

* refactor: dedicated search helper function for collection mentions

* feat: add test for collection search function helper

* feat: parseCollectionSlug

* feat: isCollectionUrl

* feat: add collection mention to paste handler

* fix: update translation of mention keyboard shortcut

* fix: keyboard shortcut mention label

* fix: missing teamId in search helper functioN

* chore: update translations

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-03-03 19:03:27 -08:00
Tom Moor 2a3ea1254c Allow links in code marks (#8625) 2025-03-03 18:55:22 -08:00
Tom Moor ddfd1b70e5 fix: Allow setting revision name to null (#8626) 2025-03-04 00:44:17 +00:00
dependabot[bot] a9b18ccf14 chore(deps-dev): bump @types/react-avatar-editor from 13.0.3 to 13.0.4 (#8619)
Bumps [@types/react-avatar-editor](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-avatar-editor) from 13.0.3 to 13.0.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-avatar-editor)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 16:43:59 -08:00
dependabot[bot] 6d3b35ef6c chore(deps): bump the aws group with 5 updates (#8618)
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.750.0` | `3.758.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.750.0` | `3.758.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.750.0` | `3.758.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.750.0` | `3.758.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.750.0` | `3.758.0` |


Updates `@aws-sdk/client-s3` from 3.750.0 to 3.758.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.758.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.750.0 to 3.758.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.758.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.750.0 to 3.758.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.758.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.750.0 to 3.758.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.758.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.750.0 to 3.758.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.758.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 14:27:00 -08:00
dependabot[bot] c7e96da95a chore(deps-dev): bump @types/react-color from 3.0.12 to 3.0.13 (#8621)
Bumps [@types/react-color](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-color) from 3.0.12 to 3.0.13.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-color)

---
updated-dependencies:
- dependency-name: "@types/react-color"
  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-03-03 14:26:29 -08:00
dependabot[bot] 3270ba7fa6 chore(deps): bump socket.io-client from 4.8.0 to 4.8.1 (#8620)
Bumps [socket.io-client](https://github.com/socketio/socket.io) from 4.8.0 to 4.8.1.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-client@4.8.0...socket.io-client@4.8.1)

---
updated-dependencies:
- dependency-name: socket.io-client
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 14:26:17 -08:00
Tom Moor fcff256586 fix: Apply full width from template (#8615) 2025-03-03 04:00:02 +00:00
Tom Moor 0cfe0fc05b Backporting more from enterprise (#8613) 2025-03-03 03:10:38 +00:00
Tom Moor 67b3e175ee Add useLocaleTime (#8608) 2025-03-02 20:18:08 +00:00
Tom Moor d3235250a8 perf: Move text serialization to task runner (#8589)
* perf: Move text serialization to task runner

* tsc

* test

* refactor

* fix: Restore previous default of toMarkdown behavior

* Stop writing text to revisions
2025-03-02 08:21:50 -08:00
Tom Moor 237253afdb fix: Flaky test ordered event expectations (#8607) 2025-03-02 13:21:25 +00:00
Tom Moor 82cdebfb66 Add name column to revisions (#8603)
* fix: Flaky test

* Migration, model interface

* Add policies to revisions

* Add revisions.update endpoint

* tests

* lint
2025-03-02 05:07:30 -08:00
Tom Moor bed0bf9ec8 feat: Add filtering to shared links admin table (#8602)
* Add query parameter to shares.list

* Add filter on shared links table

* Additional test
2025-03-01 22:22:15 +00:00
Tom Moor 4573b3fea2 fix: Danger button focus ring (#8601) 2025-03-01 21:44:38 +00:00
Tom Moor 110e489c30 fix: Reposition TOC for printing (#8600)
* Reposition TOC for printing

* refactor
2025-03-01 13:11:52 -08:00
Tom Moor b34dd138cd fix: Creates a gap cursor position between tables positioned next to each other (#8599) 2025-03-01 13:11:42 -08:00
Tom Moor 3b1ce063bf Default comments to 'Order in doc' (#8597) 2025-03-01 11:21:31 -08:00
Tom Moor b1d8acbad1 feat: Add 'Search in document' to command menu, add shortcut (#8596) 2025-03-01 10:45:31 -08:00
Tom Moor ae05520a25 feat: Add query parameter to collections.list (#8595) 2025-03-01 09:02:17 -08:00
Tom Moor 6e30bf3c64 fix: Current user presence in documents is incorrect (#8593)
* fix: Own presence in documents is not correct

* docs
2025-03-01 08:28:19 -08:00
Tom Moor 775b038359 fix: Members table always fades in (#8594)
* PeopleTable -> MemberTable

* fix: Members table always fades in
2025-03-01 08:28:09 -08:00
Tom Moor eecc7e3443 feat: Restore document search in link toolbar (#8581)
* Convert LinkEditor to functional component

* Add keyboard navigation

* cleanup

* Allow pointer selection
2025-02-28 15:01:57 -08:00
Hemachandar 5fbc57f39a fix: Check user has enabled create-comment notification in email flow (#8591) 2025-02-28 15:01:46 -08:00
Tom Moor 69029b305d test: Fix flaky availableTeams test (#8583) 2025-02-26 19:37:09 -08:00
Tom Moor 35269d7d92 feat: Badging icon for installed PWA (#8580) 2025-02-26 19:25:28 -08:00
Tom Moor 13f45e1a1c Send editor version down websocket and force reload (#8582) 2025-02-26 19:25:13 -08:00
335 changed files with 21232 additions and 3920 deletions
+5 -5
View File
@@ -147,6 +147,10 @@ DISCORD_SERVER_ID=
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
@@ -201,14 +205,10 @@ SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=
SMTP_PORT=
SMTP_SERVICE=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
+2
View File
@@ -15,6 +15,8 @@ requestInfoDefaultTitles:
requestInfoLabelToAdd: more information needed
requestInfoUserstoExclude:
- tommoor
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
+30
View File
@@ -0,0 +1,30 @@
name: Lint
on:
pull_request:
branches: [ main ]
jobs:
run-linters:
if: startsWith(github.actor, 'codegen-sh')
name: Run linters
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: 'Applied automatic fixes'
+4
View File
@@ -171,6 +171,10 @@
"description": "smtp.example.com (optional)",
"required": false
},
"SMTP_SERVICE": {
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
"required": false
},
"SMTP_PORT": {
"description": "1234 (optional)",
"required": false
+1 -1
View File
@@ -149,7 +149,7 @@ export const searchInCollection = createAction({
},
perform: ({ activeCollectionId }) => {
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
history.push(searchPath({ collectionId: activeCollectionId }));
},
});
+6 -4
View File
@@ -683,6 +683,7 @@ export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
shortcut: [`Meta+/`],
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -692,7 +693,7 @@ export const searchInDocument = createAction({
return !!document?.isActive;
},
perform: ({ activeDocumentId }) => {
history.push(searchPath(undefined, { documentId: activeDocumentId }));
history.push(searchPath({ documentId: activeDocumentId }));
},
});
@@ -805,15 +806,15 @@ export const openRandomDocument = createAction({
},
});
export const searchDocumentsForQuery = (searchQuery: string) =>
export const searchDocumentsForQuery = (query: string) =>
createAction({
id: "search",
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery)),
perform: () => history.push(searchPath({ query })),
visible: ({ location }) => location.pathname !== searchPath(),
});
@@ -1210,6 +1211,7 @@ export const rootDocumentActions = [
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
+10 -1
View File
@@ -50,7 +50,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery.query)),
perform: () => history.push(searchPath({ query: searchQuery.query })),
});
export const navigateToDrafts = createAction({
@@ -62,6 +62,15 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath()),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
+2
View File
@@ -2,6 +2,8 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
+15 -2
View File
@@ -1,15 +1,22 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/ErrorSuspended";
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";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -48,6 +55,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
@@ -72,6 +80,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
@@ -7,17 +7,43 @@ import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar, { AvatarSize } from "./Avatar";
/**
* Props for the AvatarWithPresence component
*/
type Props = {
/** The user to display the avatar for */
user: User;
/** Whether the user is currently present in the document */
isPresent: boolean;
/** Whether the user is currently editing the document */
isEditing: boolean;
/** Whether the user is currently observing the document */
isObserving: boolean;
/** Whether this avatar represents the current user */
isCurrentUser: boolean;
/** Optional click handler for the avatar */
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Size of the avatar, defaults to AvatarSize.Large */
size?: AvatarSize;
/** Optional inline styles to apply to the avatar wrapper */
style?: React.CSSProperties;
};
/**
* AvatarWithPresence component displays a user's avatar with visual indicators
* for their current status (present, editing, observing).
*
* The component shows different visual states:
* - Present users have full opacity
* - Non-present users have reduced opacity
* - Observing users have a colored border matching their user color
* - Hovering shows a colored border
*
* A tooltip displays the user's name and current status.
*
* @param props - Component properties
* @returns React component
*/
function AvatarWithPresence({
onClick,
user,
@@ -64,16 +90,33 @@ function AvatarWithPresence({
);
}
/**
* Centered container for tooltip content
*/
const Centered = styled.div`
text-align: center;
`;
/**
* Props for the AvatarPresence styled component
*/
type AvatarWrapperProps = {
/** Whether the user is currently present */
$isPresent: boolean;
/** Whether the user is currently observing */
$isObserving: boolean;
/** The user's color for border highlighting */
$color: string;
};
/**
* Styled component that wraps the Avatar and provides visual indicators
* for the user's presence status.
*
* - Adjusts opacity based on presence
* - Adds colored borders for observing users
* - Handles hover effects
*/
const AvatarPresence = styled.div<AvatarWrapperProps>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
+4
View File
@@ -80,6 +80,10 @@ const RealButton = styled(ActionButton)<RealProps>`
} 0 0 0 1px inset;
}
&:focus-visible {
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
}
&:disabled {
color: ${props.theme.textTertiary};
background: none;
+2 -2
View File
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
() =>
orderBy(
filter(
users.orderedData,
users.all,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
),
[document.collaboratorIds, users.orderedData, presentIds]
[document.collaboratorIds, users.all, presentIds]
);
// load any users we don't yet have in memory
+3
View File
@@ -11,6 +11,7 @@ import Collection from "~/models/Collection";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
@@ -24,6 +25,7 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const can = usePolicy(collection);
const handleSave = React.useMemo(
@@ -65,6 +67,7 @@ function CollectionDescription({ collection }: Props) {
maxLength={CollectionValidation.maxDescriptionLength}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
editorStyle={editorStyle}
embedsDisabled
/>
+2 -1
View File
@@ -3,6 +3,7 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -70,7 +71,7 @@ function CommandBarItem(
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{key}</Key>
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
))}
</React.Fragment>
))}
+10 -4
View File
@@ -4,6 +4,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -14,21 +20,21 @@ function ConnectionStatus() {
const { t } = useTranslation();
const codeToMessage = {
1009: {
[DocumentTooLarge.code]: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
4401: {
[AuthenticationFailed.code]: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
4403: {
[AuthorizationFailed.code]: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
4503: {
[TooManyConnections.code]: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
+6
View File
@@ -13,6 +13,7 @@ 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;
@@ -31,6 +32,7 @@ type Props = {
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
@@ -90,6 +92,7 @@ const MenuItem = (
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
@@ -158,6 +161,9 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
&: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);
+14 -29
View File
@@ -2,16 +2,11 @@ import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Optional } from "utility-types";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect from "~/components/InputSelect";
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
type DefaultCollectionInputSelectProps = {
onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null;
};
@@ -19,7 +14,6 @@ type DefaultCollectionInputSelectProps = Optional<
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
@@ -47,36 +41,26 @@ const DefaultCollectionInputSelect = ({
void fetchData();
}, [fetchError, t, fetching, collections]);
const options = React.useMemo(
const options: Option[] = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
...acc,
{
label: (
<Flex align="center">
<IconWrapper>
<CollectionIcon collection={collection} />
</IconWrapper>
{collection.name}
</Flex>
),
type: "item",
label: collection.name,
value: collection.id,
icon: <CollectionIcon collection={collection} />,
},
],
[
{
label: (
<Flex align="center">
<IconWrapper>
<HomeIcon />
</IconWrapper>
{t("Home")}
</Flex>
),
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
]
] satisfies Option[]
),
[collections.nonPrivate, t]
);
@@ -86,13 +70,14 @@ const DefaultCollectionInputSelect = ({
}
return (
<InputSelect
value={defaultCollectionId ?? "home"}
<InputSelectNew
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
label={t("Start view")}
hideLabel
short
{...rest}
/>
);
};
+4 -1
View File
@@ -23,7 +23,10 @@ function Dialogs() {
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => dialogs.closeModal(id)}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title}
style={modal.style}
>
@@ -73,15 +73,13 @@ function EditableTitle(
return;
}
if (document) {
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
},
[originalValue, value, onCancel, onSubmit]
@@ -127,7 +125,10 @@ function EditableTitle(
/>
</form>
) : (
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
<span
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
className={rest.className}
>
{value}
</span>
)}
+39 -13
View File
@@ -6,7 +6,9 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { getDataTransferFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
@@ -183,22 +185,46 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[updateComments]
);
const paragraphs = React.useMemo(() => {
if (props.readOnly && typeof props.value === "object") {
return ProsemirrorHelper.getPlainParagraphs(props.value);
}
return undefined;
}, [props.readOnly, props.value]);
return (
<ErrorBoundary component="div" reloadOnChunkMissing>
<>
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
<p key={index} dir="auto">
{paragraph.content?.map((content) => content.text)}
</p>
))}
</div>
</EditorContainer>
) : (
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
)}
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
+5 -5
View File
@@ -1,9 +1,9 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
const Empty = styled.p`
color: ${s("textTertiary")};
user-select: none;
`;
const Empty = styled(Text).attrs({
type: "tertiary",
selectable: false,
})``;
export default Empty;
+5 -4
View File
@@ -7,6 +7,7 @@ import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
@@ -77,9 +78,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Module failed to load")} />
<h1>
<Heading>
<Trans>Loading Failed</Trans>
</h1>
</Heading>
</>
)}
<Text as="p" type="secondary">
@@ -101,9 +102,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Something Unexpected Happened")} />
<h1>
<Heading>
<Trans>Something Unexpected Happened</Trans>
</h1>
</Heading>
</>
)}
<Text as="p" type="secondary">
+2 -2
View File
@@ -234,7 +234,7 @@ const lineStyle = css`
width: 1px;
height: calc(50% - 14px + 8px);
background: ${s("divider")};
mix-blend-mode: multiply;
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
@@ -255,7 +255,7 @@ const lineStyle = css`
width: 1px;
height: calc(50% - 14px);
background: ${s("divider")};
mix-blend-mode: multiply;
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
-8
View File
@@ -1,8 +0,0 @@
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
export default Fade;
+28
View File
@@ -0,0 +1,28 @@
import React from "react";
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
/**
* Fade in animation for a component.
*
* @param timing - The duration of the fade in animation, default is 250ms.
*/
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
type Props = {
children?: JSX.Element | null;
/** If true, children will be animated. */
animate: boolean;
};
/**
* Wraps children in a <Fade> if loading is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = React.useState(animate);
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
};
export default Fade;
+13 -16
View File
@@ -23,7 +23,6 @@ type Props = {
options: TFilterOption[];
selectedKeys: (string | null | undefined)[];
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
@@ -35,7 +34,6 @@ const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
selectedPrefix = "",
className,
onSelect,
showFilter,
@@ -54,9 +52,7 @@ const FilterOptions = ({
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems
.map((selected) => `${selectedPrefix} ${selected.label}`)
.join(", ")
? selectedItems.map((selected) => selected.label).join(", ")
: "";
const renderItem = React.useCallback(
@@ -70,7 +66,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon && <Icon>{option.icon}</Icon>}
{option.icon}
{option.note ? (
<LabelWithNote>
{option.label}
@@ -163,10 +159,16 @@ const FilterOptions = ({
const showFilterInput = showFilter || options.length > 10;
return (
<div>
<>
<MenuButton {...menu}>
{(props) => (
<StyledButton {...props} className={className} neutral disclosure>
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
disclosure
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
@@ -193,7 +195,7 @@ const FilterOptions = ({
/>
)}
</ContextMenu>
</div>
</>
);
};
@@ -231,6 +233,7 @@ const SearchInput = styled(Input)`
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
margin: 0;
}
${NativeInput} {
@@ -267,15 +270,9 @@ export const StyledButton = styled(Button)`
}
${Inner} {
line-height: 24px;
line-height: 28px;
min-height: auto;
}
`;
const Icon = styled.div`
margin-right: 8px;
width: 18px;
height: 18px;
`;
export default FilterOptions;
+1 -1
View File
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
right: 0;
bottom: 0;
background-color: ${s("backdrop")} !important;
z-index: ${depths.modalOverlay};
z-index: ${depths.overlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
+2 -1
View File
@@ -60,7 +60,8 @@ function InputSearchPage({
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchPath(ev.currentTarget.value, {
searchPath({
query: ev.currentTarget.value,
collectionId,
ref: source,
})
+354
View File
@@ -0,0 +1,354 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { transparentize } from "polished";
import React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import Scrollable from "./Scrollable";
import { IconWrapper } from "./Sidebar/components/SidebarLink";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import {
InputSelectRoot,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./primitives/components/InputSelect";
type Separator = {
/* Denotes a horizontal divider line to be rendered in the menu, */
type: "separator";
};
export type Item = {
/* Denotes a selectable option in the menu. */
type: "item";
/* Representative text shown in the menu for this option. */
label: string;
/* Actual value of this option. */
value: string;
/* Additional info shown alongside the label. */
description?: string;
/* An icon shown alongside the label. */
icon?: React.ReactElement;
};
export type Option = Item | Separator;
type Props = {
/* Options to display in the select menu. */
options: Option[];
/* Current chosen value. */
value?: string;
/* Callback when an option is selected. */
onChange: (value: string) => void;
/* ARIA label for accessibility. */
ariaLabel: string;
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
short?: boolean;
} & TriggerButtonProps;
export function InputSelectNew(props: Props) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
...triggerProps
} = props;
const [localValue, setLocalValue] = React.useState(value);
const [open, setOpen] = React.useState(false);
const triggerRef =
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
const contentRef =
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const isMobile = useMobile();
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
const optionsHaveIcon = options.some(
(opt) => opt.type === "item" && !!opt.icon
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator />;
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
</InputSelectItem>
);
},
[optionsHaveIcon]
);
const onValueChange = React.useCallback(
async (val: string) => {
setLocalValue(val);
onChange(val);
},
[onChange, setLocalValue]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
if (isMobile) {
return (
<MobileSelect
{...props}
value={localValue}
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
value={localValue}
onValueChange={onValueChange}
>
<InputSelectTrigger
ref={triggerRef}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
);
}
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
};
function MobileSelect(props: MobileSelectProps) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
placeholder,
optionsHaveIcon,
...triggerProps
} = props;
const [open, setOpen] = React.useState(false);
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const selectedOption = React.useMemo(
() =>
value
? options.find((opt) => opt.type === "item" && opt.value === value)
: undefined,
[value, options]
);
const handleSelect = React.useCallback(
async (val: string) => {
setOpen(false);
onChange(val);
},
[onChange]
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <Separator />;
}
const isSelected = option === selectedOption;
return (
<SelectItemWrapper
key={option.value}
onClick={() => handleSelect(option.value)}
data-state={isSelected ? "checked" : "unchecked"}
>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
{isSelected && <SelectItemIndicator />}
</SelectItemWrapper>
);
},
[handleSelect, selectedOption, optionsHaveIcon]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
/>
) : (
<>{placeholder}</>
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
function Label({ text, hidden }: { text: string; hidden: boolean }) {
const labelText = <LabelText>{text}</LabelText>;
return hidden ? (
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
) : (
labelText
);
}
function Option({
option,
optionsHaveIcon,
}: {
option: Item;
optionsHaveIcon: boolean;
}) {
const icon = optionsHaveIcon ? (
option.icon ? (
<IconWrapper>{option.icon}</IconWrapper>
) : (
<IconSpacer />
)
) : null;
return (
<OptionContainer align="center">
{icon}
{option.label}
{option.description && (
<>
&nbsp;
<Description type="tertiary" size="small" ellipsis>
{option.description}
</Description>
</>
)}
</OptionContainer>
);
}
const Wrapper = styled.label<{ short?: boolean }>`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const OptionContainer = styled(Flex)`
min-height: 24px;
`;
const Description = styled(Text)`
@media (hover: hover) {
&:hover,
&:focus {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
`;
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
+11 -9
View File
@@ -33,6 +33,7 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
ellipsis?: boolean;
};
const ListItem = (
@@ -45,6 +46,7 @@ const ListItem = (
border,
to,
keyboardNavigation,
ellipsis,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -83,7 +85,9 @@ const ListItem = (
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
<Heading $small={small} $ellipsis={ellipsis}>
{title}
</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
@@ -105,7 +109,7 @@ const ListItem = (
$border={border}
$small={small}
activeStyle={{
background: theme.accent,
background: theme.sidebarActiveBackground,
}}
{...rest}
{...rovingTabIndex}
@@ -208,10 +212,10 @@ const Image = styled(Flex)`
color: ${s("text")};
`;
const Heading = styled.p<{ $small?: boolean }>`
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
${ellipsis()}
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
@@ -219,14 +223,13 @@ const Heading = styled.p<{ $small?: boolean }>`
const Content = styled(Flex)<{ $selected: boolean }>`
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
color: ${s("text")};
`;
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
color: ${s("textTertiary")};
margin-top: -2px;
`;
@@ -234,8 +237,7 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
align-self: center;
justify-content: center;
flex-shrink: 0;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
color: ${s("textSecondary")};
`;
export default React.forwardRef(ListItem);
+5 -69
View File
@@ -1,24 +1,7 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import { locales } from "@shared/utils/date";
import Tooltip from "~/components/Tooltip";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
import { useLocaleTime } from "~/hooks/useLocaleTime";
export type Props = {
children?: React.ReactNode;
@@ -29,59 +12,12 @@ export type Props = {
format?: Partial<Record<keyof typeof locales, string>>;
};
const LocaleTime: React.FC<Props> = ({
addSuffix,
children,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
const { tooltipContent, content } = useLocaleTime(rest);
return (
<Tooltip content={tooltipContent} placement="bottom">
<time dateTime={dateTime}>{children || content}</time>
<time dateTime={rest.dateTime}>{children || content}</time>
</Tooltip>
);
};
+1 -1
View File
@@ -147,7 +147,7 @@ const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
props.$fullscreen
? transparentize(0.25, props.theme.background)
: props.theme.modalBackdrop} !important;
z-index: ${depths.modalOverlay};
z-index: ${depths.overlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -48,6 +48,15 @@ function Notifications(
notifications.approximateUnreadCount
);
}
// PWA badging
if ("setAppBadge" in navigator) {
if (notifications.approximateUnreadCount) {
void navigator.setAppBadge(notifications.approximateUnreadCount);
} else {
void navigator.clearAppBadge();
}
}
}, [notifications.approximateUnreadCount]);
return (
@@ -96,7 +96,7 @@ export const Suggestions = observer(
? users.notInDocument(document.id, query)
: collection
? users.notInCollection(collection.id, query)
: users.orderedData
: users.activeOrInvited
).filter((u) => !u.isSuspended && u.id !== user.id);
if (isEmail(query)) {
@@ -114,7 +114,7 @@ export const Suggestions = observer(
}, [
getSuggestionForEmail,
users,
users.orderedData,
users.activeOrInvited,
groups,
groups.orderedData,
document?.id,
@@ -10,6 +10,7 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
@@ -21,7 +22,6 @@ import CollectionMenu from "~/menus/CollectionMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
@@ -12,6 +12,7 @@ import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -28,7 +29,6 @@ import {
} from "../hooks/useDragAndDrop";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
+28 -1
View File
@@ -6,7 +6,11 @@ import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { io, Socket } from "socket.io-client";
import { toast } from "sonner";
import { FileOperationState, FileOperationType } from "@shared/types";
import {
FileOperationState,
FileOperationType,
ImportState,
} from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
@@ -15,6 +19,7 @@ import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupUser from "~/models/GroupUser";
import Import from "~/models/Import";
import Membership from "~/models/Membership";
import Notification from "~/models/Notification";
import Pin from "~/models/Pin";
@@ -100,6 +105,7 @@ class WebsocketProvider extends React.Component<Props> {
subscriptions,
fileOperations,
notifications,
imports,
} = this.props;
const currentUserId = auth?.user?.id;
@@ -620,6 +626,23 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => {
imports.add(event);
});
this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => {
imports.add(event);
if (
event.state === ImportState.Completed &&
event.createdBy?.id === auth.user?.id
) {
toast.success(event.name, {
description: this.props.t("Your import completed"),
});
}
});
this.socket.on(
"subscriptions.create",
(event: PartialExcept<Subscription, "id">) => {
@@ -645,6 +668,10 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => {
users.remove(event.modelId);
});
this.socket.on(
"userMemberships.update",
async (event: PartialExcept<UserMembership, "id">) => {
+88
View File
@@ -0,0 +1,88 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import React from "react";
import styled from "styled-components";
import { Drawer as DrawerPrimitive } from "vaul";
import { depths, s } from "@shared/styles";
import Flex from "../Flex";
import Text from "../Text";
import { Overlay } from "./components/Overlay";
/** Root Drawer component - all the other components are rendered inside it. */
const Drawer = (props: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root {...props} />
);
Drawer.displayName = "Drawer";
/** Drawer's trigger. */
const DrawerTrigger = DrawerPrimitive.Trigger;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DrawerPrimitive.Portal>
<DrawerPrimitive.Overlay asChild>
<Overlay />
</DrawerPrimitive.Overlay>
<StyledContent ref={ref} {...rest}>
{children}
</StyledContent>
</DrawerPrimitive.Portal>
);
});
DrawerContent.displayName = DrawerPrimitive.Content.displayName;
/** Drawer's title shown in the center. */
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>((props, ref) => {
const { hidden, children, ...rest } = props;
const title = (
<TitleWrapper justify="center">
<Text size="medium" weight="bold">
{children}
</Text>
</TitleWrapper>
);
return (
<DrawerPrimitive.Title ref={ref} {...rest} asChild>
{hidden ? (
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
) : (
<>{title}</>
)}
</DrawerPrimitive.Title>
);
});
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
/** Styled components. */
const StyledContent = styled(DrawerPrimitive.Content)`
z-index: ${depths.menu};
position: fixed;
left: 0;
right: 0;
bottom: 0;
min-width: 180px;
max-width: 100%;
min-height: 44px;
max-height: 90vh;
padding: 6px;
border-radius: 6px;
background: ${s("menuBackground")};
`;
const TitleWrapper = styled(Flex)`
padding: 8px 0;
`;
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
+135
View File
@@ -0,0 +1,135 @@
import * as InputSelectPrimitive from "@radix-ui/react-select";
import 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,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./components/InputSelect";
/** Root InputSelect component - all the other components are rendered inside it. */
const InputSelectRoot = InputSelectPrimitive.Root;
/** InputSelect's trigger. */
export type TriggerButtonProps = {
/** When true, "nude" variant of Button is rendered. */
nude?: boolean;
/** Optional css class names to pass to the trigger. */
className?: string;
} & Pick<ButtonProps<unknown>, "borderOnHover">;
type InputSelectTriggerProps = { placeholder: string } & TriggerButtonProps &
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Trigger>;
const InputSelectTrigger = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Trigger>,
InputSelectTriggerProps
>((props, ref) => {
const { placeholder, children, ...buttonProps } = props;
return (
<InputSelectPrimitive.Trigger ref={ref} asChild>
<SelectButton neutral disclosure {...buttonProps}>
<InputSelectPrimitive.Value placeholder={placeholder} />
</SelectButton>
</InputSelectPrimitive.Trigger>
);
});
InputSelectTrigger.displayName = InputSelectPrimitive.Trigger.displayName;
/** InputSelect's content - renders the options in a scrollable element. */
type ContentProps = Omit<
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Content>,
"position"
>;
const InputSelectContent = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Content>,
ContentProps
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Portal>
<StyledContent ref={ref} position={"popper"} {...rest}>
<InputSelectPrimitive.Viewport style={{ overscrollBehavior: "none" }}>
{children}
</InputSelectPrimitive.Viewport>
</StyledContent>
</InputSelectPrimitive.Portal>
);
});
InputSelectContent.displayName = InputSelectPrimitive.Content.displayName;
/** Individual InputSelect option rendered in the menu. */
const InputSelectItem = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Item>
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Item ref={ref} {...rest} asChild>
<SelectItemWrapper>
<InputSelectPrimitive.ItemText>
{children}
</InputSelectPrimitive.ItemText>
<InputSelectPrimitive.ItemIndicator asChild>
<SelectItemIndicator />
</InputSelectPrimitive.ItemIndicator>
</SelectItemWrapper>
</InputSelectPrimitive.Item>
);
});
InputSelectItem.displayName = InputSelectPrimitive.Item.displayName;
/** Horizontal separator rendered between the options. */
const InputSelectSeparator = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Separator>
>((props, ref) => (
<InputSelectPrimitive.Separator ref={ref} asChild>
<Separator {...props} />
</InputSelectPrimitive.Separator>
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
min-width: var(--radix-select-trigger-width);
max-width: 400px;
min-height: 44px;
max-height: 350px;
padding: 4px;
border-radius: 6px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
transform-origin: 50% 0;
&[data-side="bottom"] {
animation: ${fadeAndSlideDown} 200ms ease;
}
&[data-side="top"] {
animation: ${fadeAndSlideUp} 200ms ease;
}
@media print {
display: none;
}
`;
export {
InputSelectRoot,
InputSelectTrigger,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
};
@@ -0,0 +1,136 @@
/**
* Reusable components for InputSelect abstraction.
*/
import { CheckmarkIcon } from "outline-icons";
import React from "react";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Button, { Inner } from "~/components/Button";
import Flex from "~/components/Flex";
export const SelectItem = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div">
>((props, ref) => {
const { children, ...rest } = props;
return (
<ItemContainer
ref={ref}
justify="space-between"
align="center"
gap={8}
{...rest}
>
{children}
<IconSpacer />
</ItemContainer>
);
});
SelectItem.displayName = "SelectItem";
export const SelectItemIndicator = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>((props, ref) => (
<IndicatorContainer ref={ref} {...props}>
<CheckmarkIcon />
</IndicatorContainer>
));
SelectItemIndicator.displayName = "SelectItemIndicator";
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
export const SelectButton = styled(Button)<{ $nude?: boolean }>`
display: block;
font-weight: normal;
text-transform: none;
width: 100%;
cursor: var(--pointer);
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
}
&:focus-visible {
outline: none;
}
${(props) =>
props.$nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 12px;
padding-right: 4px;
}
svg {
justify-self: flex-end;
margin-left: auto;
}
&[data-placeholder=""] {
color: ${s("placeholder")};
}
`;
const ItemContainer = styled(Flex)`
position: relative;
width: 100%;
font-size: 16px;
cursor: var(--pointer);
color: ${s("textSecondary")};
background: none;
margin: 0;
padding: 12px;
border: 0;
border-radius: 4px;
outline: 0;
user-select: none;
white-space: nowrap;
svg {
flex-shrink: 0;
}
@media (hover: hover) {
&:hover,
&:focus {
color: ${s("accentText")};
background: ${s("accent")};
svg {
color: ${s("accentText")};
fill: ${s("accentText")};
}
}
}
&[data-state="checked"] {
${IconSpacer} {
display: none;
}
}
${breakpoint("tablet")`
font-size: 14px;
padding: 4px;
padding-left: 8px;
`}
`;
const IndicatorContainer = styled.span`
width: 24px;
height: 24px;
`;
@@ -0,0 +1,15 @@
import styled from "styled-components";
import { depths, s } from "@shared/styles";
export const Overlay = styled.div`
position: fixed;
inset: 0;
background: ${s("backdrop")};
z-index: ${depths.overlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
&[data-state="open"] {
opacity: 1;
}
`;
+5
View File
@@ -1,3 +1,4 @@
import { transparentize } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
@@ -13,6 +14,10 @@ const Input = styled.input`
flex-grow: 1;
min-width: 0;
&::placeholder {
color: ${(props) => transparentize(0.5, props.theme.text)};
}
@media (hover: none) and (pointer: coarse) {
font-size: 16px;
}
+224 -149
View File
@@ -1,15 +1,24 @@
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
import { observer } from "mobx-react";
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { hideScrollbars, s } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import Input from "./Input";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
@@ -32,152 +41,87 @@ type Props = {
view: EditorView;
};
type State = {
value: string;
previousValue: string;
};
const LinkEditor: React.FC<Props> = ({
mark,
from,
to,
dictionary,
onRemoveLink,
onSelectLink,
onClickLink,
view,
}) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const initialSelectionLength = to - from;
const inputRef = useRef<HTMLInputElement>(null);
const discardRef = useRef(false);
const [query, setQuery] = useState(initialValue);
const [selectedIndex, setSelectedIndex] = useState(-1);
const { documents } = useStores();
class LinkEditor extends React.Component<Props, State> {
discardInputValue = false;
initialValue = this.href;
initialSelectionLength = this.props.to - this.props.from;
inputRef = React.createRef<HTMLInputElement>();
const trimmedQuery = query.trim();
const results = trimmedQuery
? documents.findByQuery(trimmedQuery, { maxResults: 25 })
: [];
state: State = {
value: this.href,
previousValue: "",
};
const { request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
);
get href(): string {
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
}
componentDidMount(): void {
window.addEventListener("keydown", this.handleGlobalKeyDown);
}
componentWillUnmount = () => {
window.removeEventListener("keydown", this.handleGlobalKeyDown);
// If we discarded the changes then nothing to do
if (this.discardInputValue) {
return;
useEffect(() => {
if (trimmedQuery) {
void request();
}
}, [trimmedQuery, request]);
// If the link is the same as it was when the editor opened, nothing to do
if (this.state.value === this.initialValue) {
return;
}
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
inputRef.current?.select();
}
};
// If the link is totally empty or only spaces then remove the mark
const href = (this.state.value || "").trim();
if (!href) {
return this.handleRemoveLink();
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => {
window.removeEventListener("keydown", handleGlobalKeyDown);
this.save(href, href);
};
// If we discarded the changes then nothing to do
if (discardRef.current) {
return;
}
handleGlobalKeyDown = (event: KeyboardEvent): void => {
if (event.key === "k" && event.metaKey) {
this.inputRef.current?.select();
}
};
// If the link is the same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
return;
}
save = (href: string, title?: string): void => {
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return handleRemoveLink();
}
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
this.discardInputValue = true;
const { from, to } = this.props;
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
this.props.onSelectLink({ href, title, from, to });
onSelectLink({ href, title, from, to });
};
handleKeyDown = (event: React.KeyboardEvent): void => {
switch (event.key) {
case "Enter": {
event.preventDefault();
const { value } = this.state;
this.save(value, value);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (this.initialValue) {
this.setState({ value: this.initialValue }, this.moveSelectionToEnd);
} else {
this.handleRemoveLink();
}
return;
}
}
};
handleSearch = async (
event: React.ChangeEvent<HTMLInputElement>
): Promise<void> => {
const value = event.target.value;
this.setState({
value,
});
const trimmedValue = value.trim();
if (trimmedValue) {
try {
this.setState({
previousValue: trimmedValue,
});
} catch (err) {
Logger.error("Error searching for link", err);
}
}
};
handlePaste = (): void => {
setTimeout(() => this.save(this.state.value, this.state.value), 0);
};
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
try {
this.props.onClickLink(this.href, event);
} catch (err) {
toast.error(this.props.dictionary.openLinkError);
}
};
handleRemoveLink = (): void => {
this.discardInputValue = true;
const { from, to, mark, view, onRemoveLink } = this.props;
const { state, dispatch } = this.props.view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
view.focus();
};
moveSelectionToEnd = () => {
const { to, view } = this.props;
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
if (nextSelection) {
@@ -186,47 +130,178 @@ class LinkEditor extends React.Component<Props, State> {
view.focus();
};
render() {
const { view, dictionary } = this.props;
const { value } = this.state;
const isInternal = isInternalUrl(value);
const handleKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
const maxIndex = results.length - 1;
setSelectedIndex((current) => (current >= maxIndex ? 0 : current + 1));
return;
}
case "ArrowUp": {
event.preventDefault();
const maxIndex = results.length - 1;
setSelectedIndex((current) => (current <= 0 ? maxIndex : current - 1));
return;
}
case "Enter": {
event.preventDefault();
return (
if (selectedIndex >= 0 && results[selectedIndex]) {
const selectedDoc = results[selectedIndex];
const href = selectedDoc.url;
save(href, selectedDoc.title);
} else {
save(trimmedQuery, trimmedQuery);
}
if (initialSelectionLength) {
moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (initialValue) {
setQuery(initialValue);
moveSelectionToEnd();
} else {
handleRemoveLink();
}
return;
}
}
};
const handleSearch = async (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setQuery(newValue);
setSelectedIndex(-1);
};
const handlePaste = () => {
setTimeout(() => save(query, query), 0);
};
const handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
try {
onClickLink(getHref(), event);
} catch (err) {
toast.error(dictionary.openLinkError);
}
};
const handleRemoveLink = () => {
discardRef.current = true;
const { state, dispatch } = view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
view.focus();
};
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
return (
<>
<Wrapper>
<Input
ref={this.inputRef}
value={value}
placeholder={dictionary.enterLink}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleSearch}
onFocus={this.handleSearch}
autoFocus={this.href === ""}
ref={inputRef}
value={query}
placeholder={dictionary.searchOrPasteLink}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onChange={handleSearch}
onFocus={handleSearch}
autoFocus={getHref() === ""}
readOnly={!view.editable}
/>
<Tooltip
content={isInternal ? dictionary.goToLink : dictionary.openLink}
>
<ToolbarButton onClick={this.handleOpenLink} disabled={!value}>
<ToolbarButton onClick={handleOpenLink} disabled={!query}>
{isInternal ? <ArrowIcon /> : <OpenIcon />}
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
<ToolbarButton onClick={handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
)}
</Wrapper>
);
}
}
<SearchResults $hasResults={hasResults}>
<ResizingHeightContainer>
{hasResults && (
<>
{results.map((doc, index) => (
<SuggestionsMenuItem
onClick={() => {
save(doc.url, doc.title);
if (initialSelectionLength) {
moveSelectionToEnd();
}
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
key={doc.id}
subtitle={doc.collection?.name}
title={doc.title}
icon={
doc.icon ? (
<Icon value={doc.icon} color={doc.color ?? undefined} />
) : (
<DocumentIcon />
)
}
/>
))}
</>
)}
</ResizingHeightContainer>
</SearchResults>
</>
);
};
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
`;
export default LinkEditor;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
background: ${s("menuBackground")};
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
clip-path: inset(0px -100px -100px -100px);
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
margin-top: -6px;
border-radius: 0 0 4px 4px;
padding: ${(props) => (props.$hasResults ? "6px" : "0")};
max-height: 240px;
pointer-events: all;
${hideScrollbars()}
@media (hover: none) and (pointer: coarse) {
position: fixed;
top: auto;
bottom: 40px;
border-radius: 0;
max-height: 50vh;
padding: 8px 8px 4px;
}
`;
export default observer(LinkEditor);
+43 -14
View File
@@ -1,6 +1,6 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon } from "outline-icons";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -10,11 +10,13 @@ import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { DocumentsSection, UserSection } from "~/actions/sections";
import {
DocumentsSection,
UserSection,
CollectionsSection,
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
@@ -42,23 +44,19 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users } = useStores();
const { auth, documents, users, collections } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const maxResultsInSection = search ? 25 : 5;
const { loading, request } = useRequest<{
documents: Document[];
users: User[];
}>(
const { loading, request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
return {
documents: res.data.documents.map(documents.add),
users: res.data.users.map(users.add),
};
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users])
);
@@ -127,6 +125,34 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
} as MentionItem)
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
@@ -154,7 +180,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (item.attrs.type === MentionType.Document) {
if (
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
return;
}
if (!documentId) {
+2 -3
View File
@@ -645,12 +645,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<>
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
@@ -659,7 +658,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
onClick: () => handleClickItem(item),
})}
</ListItem>
</>
</React.Fragment>
);
previousHeading = currentHeading;
+11 -2
View File
@@ -1,3 +1,4 @@
import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
@@ -11,6 +12,8 @@ export type Props = {
disabled?: boolean;
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
/** Callback when the item is hovered */
onPointerMove?: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
icon?: React.ReactNode;
/** The title of the item */
@@ -25,6 +28,7 @@ function SuggestionsMenuItem({
selected,
disabled,
onClick,
onPointerMove,
title,
subtitle,
shortcut,
@@ -53,6 +57,7 @@ function SuggestionsMenuItem({
ref={ref}
active={selected}
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
icon={icon}
>
{title}
@@ -64,12 +69,16 @@ function SuggestionsMenuItem({
const Subtitle = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active ? props.theme.white50 : props.theme.textTertiary};
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
`;
const Shortcut = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active ? props.theme.white50 : props.theme.textTertiary};
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
flex-grow: 1;
text-align: right;
`;
+53 -5
View File
@@ -10,8 +10,8 @@ import {
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import { v4 } from "uuid";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { codeLanguages } from "@shared/editor/lib/code";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
@@ -20,8 +20,9 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
@@ -87,7 +88,7 @@ export default class PasteHandler extends Extension {
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(state)) {
if (isInCode(state, { inclusive: true })) {
event.preventDefault();
view.dispatch(state.tr.insertText(text));
return true;
@@ -121,6 +122,8 @@ export default class PasteHandler extends Extension {
}
// Is the link a link to a document? If so, we can grab the title and insert it.
const containsHash = text.includes("#");
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
@@ -132,7 +135,7 @@ export default class PasteHandler extends Extension {
return;
}
if (document) {
if (state.schema.nodes.mention) {
if (state.schema.nodes.mention && !containsHash) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
@@ -166,6 +169,51 @@ export default class PasteHandler extends Extension {
this.insertLink(text);
});
}
} else if (isCollectionUrl(text)) {
const slug = parseCollectionSlug(text);
if (slug) {
stores.collections
.fetch(slug)
.then((collection) => {
if (view.isDestroyed) {
return;
}
if (collection) {
if (state.schema.nodes.mention && !containsHash) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(collection.icon) ===
IconType.Emoji;
const title = `${
hasEmoji ? collection.icon + " " : ""
}${collection.name}`;
this.insertLink(`${collection.path}${hash}`, title);
}
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
this.insertLink(text);
});
}
} else {
this.insertLink(text);
}
@@ -180,7 +228,7 @@ export default class PasteHandler extends Extension {
state.tr
.replaceSelectionWith(
state.schema.nodes.code_block.create({
language: Object.keys(LANGUAGES).includes(
language: Object.keys(codeLanguages).includes(
vscodeMeta.mode
)
? vscodeMeta.mode
+1 -1
View File
@@ -23,7 +23,7 @@ export default class Suggestion extends Extension {
this.options.trigger
)}(${`[\\p{L}\/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
}\\.\\-_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
);
}
+1 -1
View File
@@ -843,7 +843,7 @@ const EditorContainer = styled(Styles)<{
${(props) =>
props.userId &&
css`
.mention[data-id=${props.userId}] {
.mention[data-id="${props.userId}"] {
color: ${props.theme.textHighlightForeground};
background: ${props.theme.textHighlight};
+13 -12
View File
@@ -2,8 +2,11 @@ import { CopyIcon, ExpandedIcon } from "outline-icons";
import { Node as ProseMirrorNode } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import { getFrequentCodeLanguages } from "@shared/editor/lib/code";
import {
getFrequentCodeLanguages,
codeLanguages,
getLabelForLanguage,
} from "@shared/editor/lib/code";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
@@ -14,20 +17,19 @@ export default function codeMenuItems(
): MenuItem[] {
const node = state.selection.$from.node();
const allLanguages = Object.entries(LANGUAGES) as [
keyof typeof LANGUAGES,
string
][];
const frequentLanguages = getFrequentCodeLanguages();
const frequentLangMenuItems = frequentLanguages.map((value) => {
const label = LANGUAGES[value];
const label = codeLanguages[value]?.label;
return langToMenuItem({ node, value, label });
});
const remainingLangMenuItems = allLanguages
.filter(([value]) => !frequentLanguages.includes(value))
.map(([value, label]) => langToMenuItem({ node, value, label }));
const remainingLangMenuItems = Object.entries(codeLanguages)
.filter(
([value]) =>
!frequentLanguages.includes(value as keyof typeof codeLanguages)
)
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
const languageMenuItems = frequentLangMenuItems.length
? [
@@ -52,8 +54,7 @@ export default function codeMenuItems(
visible: !readOnly,
name: "code_block",
icon: <ExpandedIcon />,
// @ts-expect-error We have a fallback for incorrect mapping
label: LANGUAGES[node.attrs.language ?? "none"],
label: getLabelForLanguage(node.attrs.language ?? "none"),
children: languageMenuItems,
},
];
+5
View File
@@ -1,6 +1,11 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
/**
* Hook that provides a dictionary of translated UI strings.
*
* @returns An object containing all translated UI strings used throughout the application
*/
export default function useDictionary() {
const { t } = useTranslation();
+83
View File
@@ -0,0 +1,83 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
export type Props = {
dateTime: string;
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
format?: Partial<Record<keyof typeof locales, string>>;
};
export const useLocaleTime = ({
addSuffix,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
return {
content,
tooltipContent,
};
};
+9
View File
@@ -1,6 +1,15 @@
import * as React from "react";
import useWindowSize from "./useWindowSize";
/**
* Hook to calculate the maximum height for an element based on its position and viewport size.
*
* @param options Configuration options
* @param options.elementRef A ref pointing to the element to calculate max height for
* @param options.maxViewportPercentage The maximum height of the element as a percentage of the viewport
* @param options.margin The margin to apply to the positioning
* @returns Object containing the calculated maxHeight and a function to recalculate it
*/
const useMaxHeight = ({
elementRef,
maxViewportPercentage = 90,
+6
View File
@@ -1,5 +1,11 @@
import { useState, useEffect } from "react";
/**
* Hook to check if a media query matches the current viewport.
*
* @param query The CSS media query to check against
* @returns boolean indicating whether the media query matches
*/
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(false);
+5
View File
@@ -1,6 +1,11 @@
import { breakpoints } from "@shared/styles";
import useMediaQuery from "~/hooks/useMediaQuery";
/**
* Hook to detect if the current viewport is mobile-sized.
*
* @returns boolean indicating whether the current viewport is mobile-sized
*/
export default function useMobile(): boolean {
return useMediaQuery(`(max-width: ${breakpoints.tablet - 1}px)`);
}
+3 -2
View File
@@ -18,6 +18,7 @@ export default function usePolicy(entity?: string | Model | null) {
? entity
: entity.id
: "";
const policy = policies.get(entityId);
React.useEffect(() => {
if (
@@ -28,11 +29,11 @@ export default function usePolicy(entity?: string | Model | null) {
) {
// The policy for this model is missing and we have an authenticated session, attempt to
// reload relationships for this model.
if (!policies.get(entity.id) && user) {
if (!policy && user) {
void entity.loadRelations();
}
}
}, [policies, user, entity]);
}, [policies, policy, user, entity]);
return policies.abilities(entityId);
}
+5
View File
@@ -1,6 +1,11 @@
import React from "react";
import { useLocation } from "react-router-dom";
/**
* Hook to access URL query parameters from the current location.
*
* @returns URLSearchParams object containing the current URL query parameters
*/
export default function useQuery() {
const location = useLocation();
+8
View File
@@ -24,6 +24,14 @@ export default function useQueryNotices() {
);
break;
}
case QueryNotices.UnsubscribeCollection: {
toast.success(
t("Unsubscribed from collection", {
type: "success",
})
);
break;
}
default:
}
}, [t, notice]);
+5
View File
@@ -2,6 +2,11 @@ import { MobXProviderContext } from "mobx-react";
import * as React from "react";
import RootStore from "~/stores";
/**
* Hook to access the MobX stores from the React context.
*
* @returns The root store containing all application stores
*/
export default function useStores() {
return React.useContext(MobXProviderContext) as typeof RootStore;
}
+5
View File
@@ -1,5 +1,10 @@
import * as React from "react";
/**
* Hook that executes a callback when the component unmounts.
*
* @param callback Function to be called on component unmount
*/
const useUnmount = (callback: (...args: Array<any>) => any) => {
const ref = React.useRef(callback);
ref.current = callback;
+6
View File
@@ -1,5 +1,11 @@
import { useLayoutEffect, useState } from "react";
/**
* Hook to get the current viewport height, accounting for mobile virtual keyboards.
* Uses the VisualViewport API when available, falling back to window.innerHeight.
*
* @returns The current viewport height in pixels
*/
export default function useViewportHeight(): number | void {
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
// Note: No support in Firefox at time of writing, however this mainly exists
+7
View File
@@ -13,6 +13,13 @@ const defaultOptions = {
throttle: 100,
};
/**
* Hook to track the window's scroll position.
*
* @param options Configuration options
* @param options.throttle Time in milliseconds to throttle the scroll event
* @returns Object containing the current scroll position (x, y coordinates)
*/
export default function useWindowScrollPosition(options: {
throttle: number;
}): {
+62
View File
@@ -0,0 +1,62 @@
import { observer } from "mobx-react";
import { CrossIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import Import from "~/models/Import";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
import { MenuItem } from "~/types";
type Props = {
/** Import to which actions will be applied. */
importModel: Import;
/** Callback to handle import cancellation. */
onCancel: () => Promise<void>;
/** Callback to handle import deletion. */
onDelete: () => Promise<void>;
};
export const ImportMenu = observer(
({ importModel, onCancel, onDelete }: Props) => {
const { t } = useTranslation();
const can = usePolicy(importModel);
const menu = useMenuState({
modal: true,
});
const items = React.useMemo(
() =>
[
{
type: "button",
title: t("Cancel"),
visible: can.cancel,
icon: <CrossIcon />,
dangerous: true,
onClick: onCancel,
},
{
type: "button",
title: t("Delete"),
visible: can.delete,
icon: <TrashIcon />,
dangerous: true,
onClick: onDelete,
},
] satisfies MenuItem[],
[t, can.delete, can.cancel, onCancel, onDelete]
);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Import menu options")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
);
+5
View File
@@ -92,6 +92,11 @@ export default class Collection extends ParanoidModel {
@observable
archivedBy?: User;
@computed
get searchContent(): string {
return this.name;
}
/** Returns whether the collection is empty, or undefined if not loaded. */
@computed
get isEmpty(): boolean | undefined {
+3 -2
View File
@@ -188,9 +188,10 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
collaboratorIds: string[];
@observable
@Relation(() => User)
createdBy: User | undefined;
@Relation(() => User)
@observable
updatedBy: User | undefined;
@@ -599,7 +600,7 @@ export default class Document extends ArchivableModel implements Searchable {
*/
getSummary = (blocks = 4) => ({
...this.data,
content: this.data.content.slice(0, blocks),
content: this.data.content?.slice(0, blocks),
});
@computed
+2
View File
@@ -7,6 +7,7 @@ import {
import { bytesToHumanReadable } from "@shared/utils/files";
import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
class FileOperation extends Model {
static modelName = "FileOperation";
@@ -27,6 +28,7 @@ class FileOperation extends Model {
format: FileOperationFormat;
@Relation(() => User)
user: User;
@computed
+57
View File
@@ -0,0 +1,57 @@
import { observable } from "mobx";
import { ImportableIntegrationService, ImportState } from "@shared/types";
import ImportsStore from "~/stores/ImportsStore";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterChange } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
class Import extends Model {
static modelName = "Import";
store: ImportsStore;
/** The name of the import. */
name: string;
/** Descriptive error message when the import errors out. */
error: string | null;
/** The current state of the import. */
@Field
@observable
state: ImportState;
/** The external service from which the import is created. */
service: ImportableIntegrationService;
/** The count of documents created in the import. */
@observable
documentCount: number;
/** The user who created the import. */
@Relation(() => User, {})
createdBy: User;
/** The ID of the user who created the import. */
createdById: string;
/**
* Cancel the import this will stop the import process and mark it as
* cancelled at the first opportunity.
*/
cancel = async () => this.store.cancel(this);
// hooks
@AfterChange
static removePolicies(model: Import, previousAttributes: Partial<Import>) {
if (previousAttributes.state && previousAttributes.state !== model.state) {
const { policies } = model.store.rootStore;
policies.remove(model.id);
}
}
}
export default Import;
+5
View File
@@ -4,6 +4,7 @@ import { isRTL } from "@shared/utils/rtl";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class Revision extends Model {
@@ -19,6 +20,10 @@ class Revision extends Model {
/** The document title when the revision was created */
title: string;
/** An optional name for the revision */
@Field
name: string | null;
/** Prosemirror data of the content when revision was created */
data: ProsemirrorData;
+8 -2
View File
@@ -1,12 +1,13 @@
import { observable } from "mobx";
import { computed, observable } from "mobx";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import { Searchable } from "./interfaces/Searchable";
class Share extends Model {
class Share extends Model implements Searchable {
static modelName = "Share";
@Field
@@ -65,6 +66,11 @@ class Share extends Model {
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
@computed
get searchContent(): string[] {
return [this.document?.title ?? this.documentTitle];
}
}
export default Share;
+14 -14
View File
@@ -43,12 +43,6 @@ export default abstract class Model {
this: Model,
options: { withoutPolicies?: boolean } = {}
): Promise<any> {
const relations = getRelationsForModelClass(
this.constructor as typeof Model
);
if (!relations) {
return;
}
// this is to ensure that multiple loads dont happen in parallel
if (this.loadingRelations) {
return this.loadingRelations;
@@ -56,14 +50,20 @@ export default abstract class Model {
const promises = [];
for (const properties of relations.values()) {
const store = this.store.rootStore.getStoreForModelName(
properties.relationClassResolver().modelName
);
if ("fetch" in store) {
const id = this[properties.idKey];
if (id) {
promises.push(store.fetch(id as string));
const relations = getRelationsForModelClass(
this.constructor as typeof Model
);
if (relations) {
for (const properties of relations.values()) {
const store = this.store.rootStore.getStoreForModelName(
properties.relationClassResolver().modelName
);
if ("fetch" in store) {
const id = this[properties.idKey];
if (id) {
promises.push(store.fetch(id as string));
}
}
}
}
+6 -1
View File
@@ -1,7 +1,12 @@
import { observable } from "mobx";
import { computed, observable } from "mobx";
import Model from "./Model";
export default abstract class ParanoidModel extends Model {
@observable
deletedAt: string | undefined;
@computed
get isDeleted(): boolean {
return !!this.deletedAt;
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
import DocumentNew from "~/scenes/DocumentNew";
import Error404 from "~/scenes/Error404";
import Error404 from "~/scenes/Errors/Error404";
import AuthenticatedLayout from "~/components/AuthenticatedLayout";
import CenteredContent from "~/components/CenteredContent";
import PlaceholderDocument from "~/components/PlaceholderDocument";
@@ -83,7 +83,7 @@ function AuthenticatedRoutes() {
<Route exact path={`/doc/${slug}/insights`} component={Document} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path={`${searchPath()}/:term?`} component={Search} />
<Route exact path={`${searchPath()}/:query?`} component={Search} />
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={Error404} />
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { RouteComponentProps, Switch } from "react-router-dom";
import DocumentNew from "~/scenes/DocumentNew";
import Error404 from "~/scenes/Error404";
import Error404 from "~/scenes/Errors/Error404";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
+2 -2
View File
@@ -17,7 +17,6 @@ import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Search from "~/scenes/Search";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
@@ -41,6 +40,7 @@ import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
import Error404 from "../Errors/Error404";
import Actions from "./components/Actions";
import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
@@ -139,7 +139,7 @@ const CollectionScene = observer(function _CollectionScene() {
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
if (!collection && error) {
return <Search notFound />;
return <Error404 />;
}
const hasOverview = can.update || collection?.hasDescription;
+2 -2
View File
@@ -9,8 +9,8 @@ import { s } from "@shared/styles";
import { NavigationNode, PublicTeam, TOCPosition } from "@shared/types";
import type { Theme } from "~/stores/UiStore";
import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import Error404 from "~/scenes/Errors/Error404";
import ErrorOffline from "~/scenes/Errors/ErrorOffline";
import ClickablePadding from "~/components/ClickablePadding";
import {
DocumentContextProvider,
@@ -5,7 +5,7 @@ import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import InputSelect from "~/components/InputSelect";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useQuery from "~/hooks/useQuery";
@@ -28,66 +28,80 @@ const CommentSortMenu = () => {
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved ? "resolved" : preferredSortType;
const handleSortTypeChange = (type: CommentSortType) => {
if (type !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
}
};
const handleChange = React.useCallback(
(val: string) => {
if (val === "resolved") {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
return;
}
const showResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
};
const sortType = val as CommentSortType;
if (sortType !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
sortType === CommentSortType.OrderInDocument
);
void user.save();
}
const showUnresolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
};
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
},
[history, location, sidebarContext, user, preferredSortType]
);
const options: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Most recent"),
value: CommentSortType.MostRecent,
},
{
type: "item",
label: t("Order in doc"),
value: CommentSortType.OrderInDocument,
},
{
type: "separator",
},
{
type: "item",
label: t("Resolved"),
value: "resolved",
},
] satisfies Option[],
[t]
);
return (
<Select
style={{ margin: 0 }}
ariaLabel={t("Sort comments")}
options={options}
value={value}
onChange={(ev) => {
if (ev === "resolved") {
showResolved();
} else {
handleSortTypeChange(ev as CommentSortType);
showUnresolved();
}
}}
onChange={handleChange}
ariaLabel={t("Sort comments")}
label={t("Sort comments")}
hideLabel
borderOnHover
options={[
{ value: CommentSortType.MostRecent, label: t("Most recent") },
{ value: CommentSortType.OrderInDocument, label: t("Order in doc") },
{
divider: true,
value: "resolved",
label: t("Resolved"),
},
]}
/>
);
};
const Select = styled(InputSelect)`
const Select = styled(InputSelectNew)`
color: ${s("textSecondary")};
`;
@@ -16,6 +16,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
@@ -63,7 +64,7 @@ function CommentThread({
const history = useHistory();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
const can = usePolicy(document);
@@ -156,9 +157,9 @@ function CommentThread({
React.useEffect(() => {
if (!focused && autoFocus) {
setAutoFocus(false);
setAutoFocusOff();
}
}, [focused, autoFocus]);
}, [focused, autoFocus, setAutoFocusOff]);
React.useEffect(() => {
if (focused) {
@@ -273,7 +274,7 @@ function CommentThread({
)}
</ResizingHeightContainer>
{!focused && !recessed && !draft && canReply && (
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}</Reply>
<Reply onClick={setAutoFocusOn}>{t("Reply")}</Reply>
)}
</Thread>
);
@@ -6,9 +6,10 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Error402 from "~/scenes/Error402";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import Error402 from "~/scenes/Errors/Error402";
import Error403 from "~/scenes/Errors/Error403";
import Error404 from "~/scenes/Errors/Error404";
import ErrorOffline from "~/scenes/Errors/ErrorOffline";
import { useDocumentContext } from "~/components/DocumentContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -17,6 +18,7 @@ import useStores from "~/hooks/useStores";
import { Properties } from "~/types";
import Logger from "~/utils/Logger";
import {
AuthorizationError,
NotFoundError,
OfflineError,
PaymentRequiredError,
@@ -216,6 +218,8 @@ function DataLoader({ match, children }: Props) {
<ErrorOffline />
) : error instanceof PaymentRequiredError ? (
<Error402 />
) : error instanceof AuthorizationError ? (
<Error403 />
) : (
<Error404 />
);
@@ -174,6 +174,7 @@ class DocumentScene extends React.Component<Props> {
if (template instanceof Document) {
this.props.document.templateId = template.id;
this.props.document.fullWidth = template.fullWidth;
}
if (!this.title) {
@@ -551,6 +552,11 @@ class DocumentScene extends React.Component<Props> {
>
<Notices document={document} readOnly={readOnly} />
{showContents && (
<PrintContentsContainer>
<Contents />
</PrintContentsContainer>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
@@ -665,6 +671,19 @@ const ContentsContainer = styled.div<ContentsContainerProps>`
justify-self: ${({ position }: ContentsContainerProps) =>
position === TOCPosition.Left ? "end" : "start"};
`};
@media print {
display: none;
}
`;
const PrintContentsContainer = styled.div`
display: none;
margin: 0 -12px;
@media print {
display: block;
}
`;
type EditorContainerProps = {
@@ -6,6 +6,12 @@ import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
AuthenticationFailed,
DocumentTooLarge,
EditorUpdateError,
} from "@shared/collaboration/CloseEvents";
import EDITOR_VERSION from "@shared/editor/version";
import { supportsPassiveListener } from "@shared/utils/browser";
import Editor, { Props as EditorProps } from "~/components/Editor";
import MultiplayerExtension from "~/editor/extensions/Multiplayer";
@@ -65,6 +71,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const name = `document.${documentId}`;
const localProvider = new IndexeddbPersistence(name, ydoc);
const provider = new HocuspocusProvider({
parameters: {
editorVersion: EDITOR_VERSION,
},
url: `${env.COLLABORATION_URL}/collaboration`,
name,
document: ydoc,
@@ -99,7 +108,11 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
});
provider.on("awarenessChange", (event: AwarenessChangeEvent) => {
presence.updateFromAwarenessChangeEvent(documentId, event);
presence.updateFromAwarenessChangeEvent(
documentId,
provider.awareness.clientID,
event
);
event.states.forEach(({ user, scrollY }) => {
if (user) {
@@ -136,8 +149,14 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
provider.on("close", (ev: MessageEvent) => {
if ("code" in ev.event) {
provider.shouldConnect =
ev.event.code !== 1009 && ev.event.code !== 4401;
ev.event.code !== DocumentTooLarge.code &&
ev.event.code !== AuthenticationFailed.code &&
ev.event.code !== EditorUpdateError.code;
ui.setMultiplayerStatus("disconnected", ev.event.code);
if (ev.event.code === EditorUpdateError.code) {
window.location.reload();
}
}
});
-23
View File
@@ -1,23 +0,0 @@
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import Empty from "~/components/Empty";
import Scene from "~/components/Scene";
import { homePath } from "~/utils/routeHelpers";
const Error404 = () => {
const { t } = useTranslation();
return (
<Scene title={t("Not Found")}>
<h1>{t("Not Found")}</h1>
<Empty>
<Trans>
We were unable to find the page youre looking for. Go to the{" "}
<Link to={homePath()}>homepage</Link>?
</Trans>
</Empty>
</Scene>
);
};
export default Error404;
@@ -1,7 +1,9 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import Flex from "@shared/components/Flex";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
@@ -12,13 +14,15 @@ const Error402 = () => {
return (
<Scene title={title}>
<h1>{title}</h1>
<Empty>
<Notice>
This document cannot be viewed with the current edition. Please
upgrade to a paid license to restore access.
</Notice>
</Empty>
<Heading>{title}</Heading>
<Flex style={{ maxWidth: 500 }} column>
<Empty size="large">
<Notice>
This document cannot be viewed with the current edition. Please
upgrade to a paid license to restore access.
</Notice>
</Empty>
</Flex>
</Scene>
);
};
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import Flex from "@shared/components/Flex";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import { navigateToHome } from "~/actions/definitions/navigation";
import useActionContext from "~/hooks/useActionContext";
const Error403 = () => {
const { t } = useTranslation();
const history = useHistory();
const context = useActionContext();
return (
<Scene title={t("No access to this doc")}>
<Heading>{t("No access to this doc")}</Heading>
<Flex gap={20} style={{ maxWidth: 500 }} column>
<Empty size="large">
{t(
"It doesnt look like you have permission to access this document."
)}{" "}
{t("Please request access from the document owner.")}
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} context={context} hideIcon>
{t("Home")}
</Button>
<Button onClick={history.goBack} neutral>
{t("Go back")}
</Button>
</Flex>
</Flex>
</Scene>
);
};
export default Error403;
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Flex from "@shared/components/Flex";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import {
navigateToHome,
navigateToSearch,
} from "~/actions/definitions/navigation";
import useActionContext from "~/hooks/useActionContext";
const Error404 = () => {
const { t } = useTranslation();
const context = useActionContext();
return (
<Scene title={t("Not found")}>
<Heading>{t("Not found")}</Heading>
<Flex gap={20} style={{ maxWidth: 500 }} column>
<Empty size="large">
<Trans>
The page youre looking for cannot be found. It might have been
deleted or the link is incorrect.
</Trans>
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} context={context} hideIcon>
{t("Home")}
</Button>
<Button action={navigateToSearch} context={context} neutral>
{t("Search")}
</Button>
</Flex>
</Flex>
</Scene>
);
};
export default Error404;
@@ -2,6 +2,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "~/components/CenteredContent";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
const ErrorOffline = () => {
@@ -9,7 +10,7 @@ const ErrorOffline = () => {
return (
<CenteredContent>
<PageTitle title={t("Offline")} />
<h1>{t("Offline")}</h1>
<Heading>{t("Offline")}</Heading>
<Empty>{t("We were unable to load the document while offline.")}</Empty>
</CenteredContent>
);
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import useStores from "~/hooks/useStores";
@@ -12,12 +13,12 @@ const ErrorSuspended = () => {
return (
<CenteredContent>
<PageTitle title={t("Your account has been suspended")} />
<h1>
<Heading>
<span role="img" aria-label={t("Warning Sign")}>
</span>{" "}
{t("Your account has been suspended")}
</h1>
</Heading>
<p>
<Trans
+1 -8
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { HomeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Switch, Route, Redirect } from "react-router-dom";
import { Switch, Route } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Action } from "~/components/Actions";
@@ -18,7 +18,6 @@ import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -29,16 +28,10 @@ function Home() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const [spendPostLoginPath] = usePostLoginPath();
const userId = user?.id;
const { pins, count } = usePinnedDocuments("home");
const can = usePolicy(team);
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
return (
<Scene
icon={<HomeIcon />}
+1 -1
View File
@@ -462,7 +462,7 @@ function KeyboardShortcuts() {
items: [
{
shortcut: "@",
label: t("Mention user or document"),
label: t("Mention users and more"),
},
{
shortcut: ":",
+13 -27
View File
@@ -8,14 +8,13 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { v4 as uuidv4 } from "uuid";
import { Pagination } from "@shared/constants";
import { hover, hideScrollbars } from "@shared/styles";
import { hideScrollbars } from "@shared/styles";
import {
DateFilter as TDateFilter,
StatusFilter as TStatusFilter,
} from "@shared/types";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DocumentListItem from "~/components/DocumentListItem";
import Empty from "~/components/Empty";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
@@ -38,9 +37,7 @@ import RecentSearches from "./components/RecentSearches";
import SearchInput from "./components/SearchInput";
import UserFilter from "./components/UserFilter";
type Props = { notFound?: boolean };
function Search(props: Props) {
function Search() {
const { t } = useTranslation();
const { documents, searches } = useStores();
@@ -48,7 +45,7 @@ function Search(props: Props) {
const params = useQuery();
const location = useLocation();
const history = useHistory();
const routeMatch = useRouteMatch<{ term: string }>();
const routeMatch = useRouteMatch<{ query: string }>();
// refs
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
@@ -57,13 +54,13 @@ function Search(props: Props) {
// filters
const decodedQuery = decodeURIComponentSafe(
routeMatch.params.term ?? params.get("query") ?? ""
routeMatch.params.query ?? params.get("q") ?? params.get("query") ?? ""
).trim();
const query = decodedQuery !== "" ? decodedQuery : undefined;
const collectionId = params.get("collectionId") ?? undefined;
const userId = params.get("userId") ?? undefined;
const collectionId = params.get("collectionId") ?? "";
const userId = params.get("userId") ?? "";
const documentId = params.get("documentId") ?? undefined;
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? undefined;
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? "";
const statusFilter = params.getAll("statusFilter")?.length
? (params.getAll("statusFilter") as TStatusFilter[])
: [TStatusFilter.Published, TStatusFilter.Draft];
@@ -130,9 +127,9 @@ function Search(props: Props) {
const updateLocation = (query: string) => {
history.replace({
pathname: searchPath(query),
pathname: location.pathname,
search: queryString.stringify(
{ ...queryString.parse(location.search), query: undefined },
{ ...queryString.parse(location.search), q: query },
{
skipEmptyString: true,
}
@@ -153,7 +150,7 @@ function Search(props: Props) {
history.replace({
pathname: location.pathname,
search: queryString.stringify(
{ ...queryString.parse(location.search), query: undefined, ...search },
{ ...queryString.parse(location.search), ...search },
{
skipEmptyString: true,
}
@@ -211,14 +208,6 @@ function Search(props: Props) {
<Scene textTitle={query ? `${query} ${t("Search")}` : t("Search")}>
<RegisterKeyDown trigger="Escape" handler={history.goBack} />
{loading && <LoadingIndicator />}
{props.notFound && (
<div>
<h1>{t("Not Found")}</h1>
<Empty>
{t("We were unable to find the page youre looking for.")}
</Empty>
</div>
)}
<ResultsWrapper column auto>
<form
method="GET"
@@ -375,27 +364,24 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
const Filters = styled(Flex)`
margin-bottom: 12px;
opacity: 0.85;
transition: opacity 100ms ease-in-out;
overflow-y: hidden;
overflow-x: auto;
padding: 8px 0;
height: 28px;
gap: 8px;
${hideScrollbars()}
${breakpoint("tablet")`
padding: 0;
`};
&: ${hover} {
opacity: 1;
}
`;
const SearchTitlesFilter = styled(Switch)`
white-space: nowrap;
margin-left: 8px;
margin-top: 2px;
margin-top: 4px;
font-size: 14px;
font-weight: 400;
`;
@@ -21,13 +21,13 @@ function CollectionFilter(props: Props) {
const collectionOptions = collections.orderedData.map((collection) => ({
key: collection.id,
label: collection.name,
icon: <CollectionIcon collection={collection} size={18} />,
icon: <CollectionIcon collection={collection} size={24} />,
}));
return [
{
key: "",
label: t("Any collection"),
icon: <SVGCollectionIcon size={18} />,
icon: <SVGCollectionIcon size={24} />,
},
...collectionOptions,
];
@@ -39,7 +39,6 @@ function CollectionFilter(props: Props) {
selectedKeys={[collectionId]}
onSelect={onSelect}
defaultLabel={t("Any collection")}
selectedPrefix={`${t("Collection")}:`}
showFilter
/>
);
+1 -1
View File
@@ -16,7 +16,7 @@ const DateFilter = ({ dateFilter, onSelect }: Props) => {
() => [
{
key: "",
label: t("Any time"),
label: t("All time"),
},
{
key: "day",
@@ -19,7 +19,7 @@ export function DocumentFilter(props: Props) {
<div>
<Tooltip content={t("Remove document filter")}>
<StyledButton onClick={props.onClick} icon={<CloseIcon />} neutral>
{props.document.title}
{props.document.titleWithDefault}
</StyledButton>
</Tooltip>
</div>
@@ -27,7 +27,7 @@ function RecentSearchListItem({ searchQuery }: Props) {
return (
<RecentSearch
to={searchPath(searchQuery.query)}
to={searchPath({ query: searchQuery.query })}
ref={ref}
{...rovingTabIndex}
>
@@ -51,7 +51,9 @@ const RemoveButton = styled(NudeButton)`
opacity: 0;
color: ${s("textTertiary")};
&:hover {
&:focus,
&:${hover} {
opacity: 1;
color: ${s("text")};
}
`;
@@ -61,17 +63,11 @@ const RecentSearch = styled(Link)`
justify-content: space-between;
color: ${s("textSecondary")};
cursor: var(--pointer);
padding: 1px 4px;
padding: 1px 8px;
border-radius: 4px;
position: relative;
line-height: 24px;
font-size: 14px;
&:before {
content: "·";
color: ${s("textTertiary")};
position: absolute;
left: -8px;
}
margin: 0 -8px;
&:focus-visible {
outline: none;
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import Fade from "~/components/Fade";
import { ConditionalFade } from "~/components/Fade";
import useStores from "~/hooks/useStores";
import RecentSearchListItem from "./RecentSearchListItem";
@@ -19,7 +19,6 @@ function RecentSearches(
) {
const { searches } = useStores();
const { t } = useTranslation();
const [isPreloaded] = React.useState(searches.recent.length > 0);
React.useEffect(() => {
void searches.fetchPage({
@@ -48,7 +47,11 @@ function RecentSearches(
</>
) : null;
return isPreloaded ? content : <Fade>{content}</Fade>;
return (
<ConditionalFade animate={!searches.recent.length}>
{content}
</ConditionalFade>
);
}
const Heading = styled.h2`
@@ -56,7 +59,7 @@ const Heading = styled.h2`
font-size: 14px;
line-height: 1.5;
color: ${s("textSecondary")};
margin-bottom: 0;
margin: 12px 0 0;
`;
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
+4 -5
View File
@@ -25,13 +25,13 @@ function UserFilter(props: Props) {
const userOptions = users.all.map((user) => ({
key: user.id,
label: user.name,
icon: <Avatar model={user} size={AvatarSize.Small} />,
icon: <StyledAvatar model={user} size={AvatarSize.Small} />,
}));
return [
{
key: "",
label: t("Any author"),
icon: <NoAuthor size={20} />,
icon: <UserIcon size={20} />,
},
...userOptions,
];
@@ -43,7 +43,6 @@ function UserFilter(props: Props) {
selectedKeys={[userId]}
onSelect={onSelect}
defaultLabel={t("Any author")}
selectedPrefix={`${t("Author")}:`}
fetchQuery={users.fetchPage}
fetchQueryOptions={fetchQueryOptions}
showFilter
@@ -51,8 +50,8 @@ function UserFilter(props: Props) {
);
}
const NoAuthor = styled(UserIcon)`
margin-left: -2px;
const StyledAvatar = styled(Avatar)`
margin: 2px;
`;
export default observer(UserFilter);
+31 -18
View File
@@ -16,7 +16,7 @@ import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSel
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import InputColor from "~/components/InputColor";
import InputSelect from "~/components/InputSelect";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -64,6 +64,27 @@ function Details() {
team.getPreference(TeamPreference.TocPosition) as TOCPosition
);
const tocPositionOptions: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Left"),
value: TOCPosition.Left,
},
{
type: "item",
label: t("Right"),
value: TOCPosition.Right,
},
] satisfies Option[],
[t]
);
const handleTocPositionChange = React.useCallback((position: string) => {
setTocPosition(position as TOCPosition);
}, []);
const handleSubmit = React.useCallback(
async (event?: React.SyntheticEvent) => {
if (event) {
@@ -123,9 +144,9 @@ function Details() {
});
};
const onSelectCollection = React.useCallback(async (value: string) => {
const defaultCollectionId = value === "home" ? null : value;
setDefaultCollectionId(defaultCollectionId);
const onSelectCollection = React.useCallback((value: string) => {
const selectedValue = value === "home" ? null : value;
setDefaultCollectionId(selectedValue);
}, []);
const isValid = form.current?.checkValidity();
@@ -242,20 +263,13 @@ function Details() {
"The side to display the table of contents in relation to the main content."
)}
>
<InputSelect
ariaLabel={t("Table of contents position")}
options={[
{
label: t("Left"),
value: TOCPosition.Left,
},
{
label: t("Right"),
value: TOCPosition.Right,
},
]}
<InputSelectNew
options={tocPositionOptions}
value={tocPosition}
onChange={(p: TOCPosition) => setTocPosition(p)}
onChange={handleTocPositionChange}
ariaLabel={t("Table of contents position")}
label={t("Table of contents position")}
hideLabel
/>
</SettingRow>
@@ -298,7 +312,6 @@ function Details() {
)}
>
<DefaultCollectionInputSelect
id="defaultCollectionId"
onSelectCollection={onSelectCollection}
defaultCollectionId={defaultCollectionId}
/>
+12 -9
View File
@@ -10,6 +10,7 @@ import Group from "~/models/Group";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
@@ -149,15 +150,17 @@ function Groups() {
onChange={handleSearch}
/>
</StickyFilters>
<GroupsTable
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
<ConditionalFade animate={!data}>
<GroupsTable
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
</>
)}
</Scene>
+154 -88
View File
@@ -1,10 +1,13 @@
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import { NewDocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Pagination } from "@shared/constants";
import { FileOperationType } from "@shared/types";
import { cdnPath } from "@shared/utils/urls";
import FileOperation from "~/models/FileOperation";
import ImportModel from "~/models/Import";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
@@ -15,16 +18,146 @@ import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { Hook, PluginManager } from "~/utils/PluginManager";
import FileOperationListItem from "./components/FileOperationListItem";
import ImportJSONDialog from "./components/ImportJSONDialog";
import { ImportListItem } from "./components/ImportListItem";
import ImportMarkdownDialog from "./components/ImportMarkdownDialog";
import ImportNotionDialog from "./components/ImportNotionDialog";
type Config = {
/** The title of the import. */
title: string;
/** The auxiliary descriptive text of the import. */
subtitle: string;
/** An icon to denote the kind of import. */
icon: React.ReactElement;
/** Trigger for the import. */
action: React.ReactElement;
};
function useImportsConfig() {
const { t } = useTranslation();
const { dialogs } = useStores();
const appName = env.APP_NAME;
return React.useMemo(() => {
const items: Config[] = [
{
title: t("Markdown"),
subtitle: t(
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
),
icon: <MarkdownIcon size={28} />,
action: (
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportMarkdownDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
),
},
{
title: "JSON",
subtitle: t(
"Import a JSON data file exported from another {{ appName }} instance",
{
appName,
}
),
icon: <OutlineIcon size={28} cover />,
action: (
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportJSONDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
),
},
];
PluginManager.getHooks(Hook.Imports).forEach((plugin) => {
items.push({ ...plugin.value });
});
items.push({
title: "Confluence",
subtitle: t("Import pages from a Confluence instance"),
icon: <img src={cdnPath("/images/confluence.png")} width={28} />,
action: (
<Button type="submit" disabled neutral>
{t("Enterprise")}
</Button>
),
});
return items;
}, [t, dialogs, appName]);
}
function Import() {
const { t } = useTranslation();
const { dialogs, fileOperations } = useStores();
const { fileOperations, imports } = useStores();
const configs = useImportsConfig();
const appName = env.APP_NAME;
const [, setForceRender] = React.useState(0);
const offset = React.useMemo(() => ({ imports: 0, fileOperations: 0 }), []);
const fetchImports = React.useCallback(async () => {
const [importsArr, fileOpsArr] = await Promise.all([
imports.fetchPage({
offset: offset.imports,
limit: Pagination.defaultLimit,
}),
fileOperations.fetchPage({
type: FileOperationType.Import,
offset: offset.fileOperations,
limit: Pagination.defaultLimit,
}),
]);
const pageImports = orderBy(
[...importsArr, ...fileOpsArr],
"createdAt",
"desc"
).slice(0, Pagination.defaultLimit);
const apiImportsCount = pageImports.filter(
(item) => item instanceof ImportModel
).length;
offset.imports += apiImportsCount;
offset.fileOperations += pageImports.length - apiImportsCount;
// needed to re-render after mobx store and offset is updated
setForceRender((s) => ++s);
return pageImports;
}, [imports, fileOperations, offset]);
const allImports = orderBy(
[
...imports.orderedData,
...fileOperations.filter({ type: FileOperationType.Import }),
],
"createdAt",
"desc"
).slice(0, offset.imports + offset.fileOperations);
return (
<Scene title={t("Import")} icon={<NewDocumentIcon />}>
<Heading>{t("Import")}</Heading>
@@ -38,100 +171,33 @@ function Import() {
</Text>
<div>
<Item
border={false}
image={<MarkdownIcon size={28} />}
title={t("Markdown")}
subtitle={t(
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
)}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportMarkdownDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<OutlineIcon size={28} cover />}
title="JSON"
subtitle={t(
"Import a JSON data file exported from another {{ appName }} instance",
{
appName,
}
)}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportJSONDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/notion.png")} width={28} />}
title="Notion"
subtitle={t("Import pages exported from Notion")}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportNotionDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/confluence.png")} width={28} />}
title="Confluence"
subtitle={t("Import pages from a Confluence instance")}
actions={
<Button type="submit" disabled neutral>
{t("Enterprise")}
</Button>
}
/>
{configs.map((config) => (
<Item
key={config.title}
title={config.title}
subtitle={config.subtitle}
image={config.icon}
actions={config.action}
border={false}
/>
))}
</div>
<br />
<PaginatedList
items={fileOperations.imports}
fetch={fileOperations.fetchPage}
options={{
type: FileOperationType.Import,
}}
items={allImports}
fetch={fetchImports}
heading={
<h2>
<Trans>Recent imports</Trans>
</h2>
}
renderItem={(item: FileOperation) => (
<FileOperationListItem key={item.id} fileOperation={item} />
)}
renderItem={(item: ImportModel | FileOperation) =>
item instanceof ImportModel ? (
<ImportListItem key={item.id} importModel={item} />
) : (
<FileOperationListItem key={item.id} fileOperation={item} />
)
}
/>
</Scene>
);
+6 -6
View File
@@ -9,7 +9,7 @@ import styled from "styled-components";
import UsersStore, { queriedUsers } from "~/stores/UsersStore";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
@@ -22,7 +22,7 @@ import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { PeopleTable } from "./components/PeopleTable";
import { MembersTable } from "./components/MembersTable";
import { StickyFilters } from "./components/StickyFilters";
import UserRoleFilter from "./components/UserRoleFilter";
import UserStatusFilter from "./components/UserStatusFilter";
@@ -163,8 +163,8 @@ function Members() {
onSelect={handleRoleFilter}
/>
</StickyFilters>
<Fade>
<PeopleTable
<ConditionalFade animate={!data}>
<MembersTable
data={data ?? []}
sort={sort}
canManage={can.update}
@@ -174,7 +174,7 @@ function Members() {
fetchNext: next,
}}
/>
</Fade>
</ConditionalFade>
</Scene>
);
}
@@ -194,7 +194,7 @@ function getFilteredUsers({
switch (filter) {
case "all":
filteredUsers = users.orderedData;
filteredUsers = users.all;
break;
case "suspended":
filteredUsers = users.suspended;
+49 -19
View File
@@ -3,12 +3,12 @@ import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { languageOptions } from "@shared/i18n";
import { languageOptions as availableLanguages } from "@shared/i18n";
import { TeamPreference, UserPreference } from "@shared/types";
import { Theme } from "~/stores/UiStore";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import InputSelect from "~/components/InputSelect";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -26,6 +26,29 @@ function Preferences() {
const team = useCurrentTeam();
const can = usePolicy(user.id);
const languageOptions: Option[] = React.useMemo(
() =>
availableLanguages.map(
(lang) =>
({
type: "item",
label: lang.label,
value: lang.value,
} satisfies Option)
),
[]
);
const themeOptions: Option[] = React.useMemo(
() =>
[
{ type: "item", label: t("Light"), value: Theme.Light },
{ type: "item", label: t("Dark"), value: Theme.Dark },
{ type: "item", label: t("System"), value: Theme.System },
] satisfies Option[],
[t]
);
const handlePreferenceChange =
(inverted = false) =>
async (ev: React.ChangeEvent<HTMLInputElement>) => {
@@ -37,10 +60,21 @@ function Preferences() {
toast.success(t("Preferences saved"));
};
const handleLanguageChange = async (language: string) => {
await user.save({ language });
toast.success(t("Preferences saved"));
};
const handleLanguageChange = React.useCallback(
async (language: string) => {
await user.save({ language });
toast.success(t("Preferences saved"));
},
[t, user]
);
const handleThemeChange = React.useCallback(
(theme) => {
ui.setTheme(theme as Theme);
toast.success(t("Preferences saved"));
},
[t, ui]
);
const showDeleteAccount = () => {
dialogs.openModal({
@@ -77,12 +111,13 @@ function Preferences() {
</>
}
>
<InputSelect
id="language"
<InputSelectNew
options={languageOptions}
value={user.language}
onChange={handleLanguageChange}
ariaLabel={t("Language")}
label={t("Language")}
hideLabel
/>
</SettingRow>
<SettingRow
@@ -90,18 +125,13 @@ function Preferences() {
label={t("Appearance")}
description={t("Choose your preferred interface color scheme.")}
>
<InputSelect
<InputSelectNew
options={themeOptions}
value={ui.theme}
onChange={handleThemeChange}
ariaLabel={t("Appearance")}
options={[
{ label: t("Light"), value: Theme.Light },
{ label: t("Dark"), value: Theme.Dark },
{ label: t("System"), value: Theme.System },
]}
value={ui.resolvedTheme}
onChange={(theme) => {
ui.setTheme(theme as Theme);
toast.success(t("Preferences saved"));
}}
label={t("Appearance")}
hideLabel
/>
</SettingRow>
<SettingRow

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