Compare commits

...

76 Commits

Author SHA1 Message Date
hmacr 8d5f689e00 extract completionPercentage to separate method 2025-04-24 12:29:20 +05:30
hmacr 868e01c693 determine completion percentage 2025-04-24 12:13:24 +05:30
hmacr 9664a597d9 use workspace key, reduce icon size 2025-04-24 10:13:43 +05:30
hmacr bafa3bbd46 cleanup 2025-04-23 23:06:31 +05:30
hmacr f731eb1cce i18n 2025-04-23 21:01:24 +05:30
hmacr 22c4cdf707 lint 2025-04-23 20:59:08 +05:30
hmacr 989bfd7422 uninstall hook 2025-04-23 20:54:22 +05:30
hmacr dde1c06a2f linear icon 2025-04-23 20:38:31 +05:30
hmacr bbe46700d2 state icon 2025-04-23 20:32:50 +05:30
hmacr 9fc60d0f04 fetch labels 2025-04-23 12:43:32 +05:30
hmacr ac46d736a0 unfurl impl fix for recent merge from main 2025-04-23 12:14:45 +05:30
hmacr 3f52b6cb1f unfurl 2025-04-23 11:56:11 +05:30
hmacr 9841ac1a21 linear settings and oauth 2025-04-23 11:56:08 +05:30
codegen-sh[bot] 943fd7e2e1 refactor: Convert Frame component to functional component (#8943)
* refactor: Convert Frame component to functional component

* fix: Fix linting issues in Frame component

* tsc

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-23 01:53:29 +00:00
Tom Moor 01db19a0b1 fix: Cannot load avatars in some instances (#9025) 2025-04-22 21:23:51 -04:00
Hemachandar 51cb5bffce Cache issueSources for embed integrations (#8952)
* Cache `issueSources` for embed integrations

* lock model before update
2025-04-22 09:59:39 -04:00
Hemachandar d37b7fa31e Transform issue and pull_request to unfurl shape in plugin (#9006)
* Transform issue and pull_request to unfurl shape in plugin

* better typings

* add todo
2025-04-22 07:00:44 -04:00
dependabot[bot] f86225c332 chore(deps): bump vite-plugin-pwa from 0.20.3 to 0.21.2 (#9021)
Bumps [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) from 0.20.3 to 0.21.2.
- [Release notes](https://github.com/vite-pwa/vite-plugin-pwa/releases)
- [Commits](https://github.com/vite-pwa/vite-plugin-pwa/compare/v0.20.3...v0.21.2)

---
updated-dependencies:
- dependency-name: vite-plugin-pwa
  dependency-version: 0.21.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 06:51:47 -04:00
Tom Moor e53c90f25f fix: Input validation on desktop app subdomain dialog (#9004)
* Improve validation on desktop subdomain switch modal

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* lint

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-04-21 20:02:48 -04:00
dependabot[bot] d84d5a4b09 chore(deps): bump @notionhq/client from 2.2.16 to 2.3.0 (#9022)
Bumps [@notionhq/client](https://github.com/makenotion/notion-sdk-js) from 2.2.16 to 2.3.0.
- [Release notes](https://github.com/makenotion/notion-sdk-js/releases)
- [Commits](https://github.com/makenotion/notion-sdk-js/compare/v2.2.16...v2.3.0)

---
updated-dependencies:
- dependency-name: "@notionhq/client"
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-21 19:30:22 -04:00
dependabot[bot] 0031fc1562 chore(deps): bump dotenv from 16.4.7 to 16.5.0 (#9020)
Bumps [dotenv](https://github.com/motdotla/dotenv) from 16.4.7 to 16.5.0.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v16.4.7...v16.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-21 19:29:23 -04:00
dependabot[bot] 9b73635727 chore(deps): bump @radix-ui/react-visually-hidden from 1.1.2 to 1.2.0 (#9023)
Bumps [@radix-ui/react-visually-hidden](https://github.com/radix-ui/primitives) from 1.1.2 to 1.2.0.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-visually-hidden"
  dependency-version: 1.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-21 19:29:04 -04:00
dependabot[bot] 5cefb534cc chore(deps): bump rfc6902 from 5.1.1 to 5.1.2 (#9024)
Bumps [rfc6902](https://github.com/chbrown/rfc6902) from 5.1.1 to 5.1.2.
- [Commits](https://github.com/chbrown/rfc6902/compare/v5.1.1...v5.1.2)

---
updated-dependencies:
- dependency-name: rfc6902
  dependency-version: 5.1.2
  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-04-21 19:28:52 -04:00
Tom Moor 8fb6f7f8c6 fix: Overflow on math blocks (#9026) 2025-04-21 19:28:30 -04:00
Tom Moor 6b497cf1ec fix: IME composition between backticks (#9011) 2025-04-19 16:24:22 -04:00
Tom Moor 05a61927af fix: Improve settings table layout on mobile (#9012) 2025-04-19 16:24:14 -04:00
Tom Moor 2b07f412e2 fix: Image caption is not correctly centered on full-width image (#9013) 2025-04-18 19:31:36 -04:00
Hemachandar 65bb3b11f3 fix: Parse emoji and url only as workspace icon (#9009)
* fix: Parse emoji and url only as workspace icon

* scope emoji regex to transform function
2025-04-18 10:45:17 -04:00
Tom Moor e1e334dd5f fix: Deleted users appear in mention menu before search query (#9003) 2025-04-17 22:57:43 -04:00
codegen-sh[bot] 6e9092bcaf #8962: Remove "Self hosted" integrations page (#9001)
* #8962: Remove "Self hosted" integrations page

* Remove unused BuildingBlocksIcon import

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-17 08:34:08 -04:00
Tom Moor 09a4b76aae fix: Users subscribed to document and collection may be notified twice (#8997)
fix: Create notifications in transaction
2025-04-17 08:08:09 -04:00
Hemachandar 5789d65bf5 Ensure iframely fallback is not executed for connected unfurl integration (#8995)
* Ensure iframely fallback is not executed for connected unfurl integration

* tsc
2025-04-16 18:22:51 -04:00
Tom Moor 03a0f54236 fix: Cannot drag-select text while editing document title in sidebar (#8991)
* fix: Cannot drag-select text while editing document title in sidebar

* Clarify isEditing parameter description
2025-04-16 18:22:43 -04:00
Tom Moor 1e7244c737 fix: Infinite loop loading page with vbnet code embed (#8987) 2025-04-16 01:51:57 +00:00
Tom Moor 96c41ce823 chore: Disable bundle-size job on forks (#8986) 2025-04-16 01:31:25 +00:00
Tom Moor 0702570b0d fix: Small modal overflow scrolling behavior (#8981)
closes #8966
2025-04-15 06:35:33 -07:00
Tom Moor 4b209a7913 fix: Full-width image control should act as toggle (#8980)
closes #8954
2025-04-15 12:22:14 +00:00
Tom Moor 6393bd02f4 fix: Cannot select divider, closes #8964 (#8979) 2025-04-15 12:13:45 +00:00
dependabot[bot] 1776aad833 chore(deps): bump the aws group with 5 updates (#8968)
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.782.0` | `3.787.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.782.0` | `3.787.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.782.0` | `3.787.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.782.0` | `3.787.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.782.0` | `3.787.0` |


Updates `@aws-sdk/client-s3` from 3.782.0 to 3.787.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.787.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.782.0 to 3.787.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.787.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.782.0 to 3.787.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.787.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.782.0 to 3.787.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.787.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.782.0 to 3.787.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.787.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-14 22:40:15 -04:00
dependabot[bot] 0c6b37cb60 chore(deps): bump react-virtualized-auto-sizer from 1.0.25 to 1.0.26 (#8969)
Bumps [react-virtualized-auto-sizer](https://github.com/bvaughn/react-virtualized-auto-sizer) from 1.0.25 to 1.0.26.
- [Release notes](https://github.com/bvaughn/react-virtualized-auto-sizer/releases)
- [Changelog](https://github.com/bvaughn/react-virtualized-auto-sizer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bvaughn/react-virtualized-auto-sizer/compare/1.0.25...1.0.26)

---
updated-dependencies:
- dependency-name: react-virtualized-auto-sizer
  dependency-version: 1.0.26
  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-04-14 22:32:45 -04:00
Tom Moor d664044579 perf: Cache promise when loading code languages (#8975) 2025-04-15 02:31:08 +00:00
dependabot[bot] b3ca434c51 chore(deps): bump prosemirror-schema-list from 1.4.1 to 1.5.1 (#8970)
Bumps [prosemirror-schema-list](https://github.com/prosemirror/prosemirror-schema-list) from 1.4.1 to 1.5.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-schema-list/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-schema-list/compare/1.4.1...1.5.1)

---
updated-dependencies:
- dependency-name: prosemirror-schema-list
  dependency-version: 1.5.1
  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-04-14 22:28:31 -04:00
dependabot[bot] 631b75def4 chore(deps-dev): bump typescript from 5.8.2 to 5.8.3 (#8972)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.8.2 to 5.8.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.2...v5.8.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 5.8.3
  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-04-14 22:28:05 -04:00
Tom Moor d183dab063 fix: Mermaid diagrams hanging (#8961) 2025-04-14 12:47:57 +00:00
Hemachandar f082da6456 fix: Reset avatar zoom, set missing text in upload button (#8949)
* fix: Reset avatar zoom, set missing text in upload button

* tiny
2025-04-13 19:15:15 -07:00
Tom Moor ad72210714 fix: Hardcode dynamic imports to avoid production issues (#8958)
* fix: Hardcode dynamic imports to avoid production issues

* tsc
2025-04-13 19:07:47 -07:00
Tom Moor 9c85b26d43 fix: Editor crashes on shared page with no user (#8956) 2025-04-13 21:48:08 +00:00
Hemachandar bf6a56849e Show GitHub issues and pull requests as mentions (#8870)
* mention issue works

* pr and loading works

* error node

* tweak mention display

* handle multiple creation error

* tidy

* store unfurl in mention attrs

* simplify mention code creation

* test fix

* base feedback

* update node when pos is available

* delete local UnfurlsStore

* use unfurl from store

* Optimize lodash isMatch import statement

* fix: Copy/paste of issue mentions
fix: Icon alignment
fix: Error and loading mentions are unselectable

* Switch order in paste menu

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-13 06:09:13 -07:00
Tom Moor 68e8b2791a fix: Line numbers flash in on load (#8948)
fix: Text color of plain text and markdown code blocks
2025-04-12 18:25:15 -07:00
Tom Moor 89db519b72 Replace embed icon (#8947) 2025-04-12 19:40:08 +00:00
codegen-sh[bot] 31c412b4a6 refactor: Convert ImageUpload component to functional (#8944)
* refactor: Convert ImageUpload component to functional

* fix: Fix linting issues by removing trailing whitespace and unused imports

* Applied automatic fixes

* translations

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-12 19:26:38 +00:00
Tom Moor 199584428a fix: Markdown copy should not occur for single node situations (#8946) 2025-04-12 12:15:52 -07:00
Tom Moor f22780e944 Move editor syntax highlighting to async (#8934)
* Move editor syntax highlighting to async, add a bunch more languages

* Remove vestigial referenecs to Prism

* fix: bundle-size job not triggering

* Add webpackStatsFile
2025-04-12 10:55:47 -07:00
dependabot[bot] a71381785c chore(deps): bump vite from 5.4.17 to 5.4.18 (#8941)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.17 to 5.4.18.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.18/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.18/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.18
  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-04-12 10:55:27 -07:00
Tom Moor a61b53aa74 Require collection manage permissions to export (#8942) 2025-04-12 10:55:15 -07:00
Tom Moor 45f0885533 fix: bundle-size CI (#8940) 2025-04-12 10:07:48 -07:00
Hemachandar 9c9657f4cc eslint: Increase severity to error for lodash imports (#8932) 2025-04-11 17:56:28 -07:00
Tom Moor c3de0cf0ec v0.83.0 (#8928) 2025-04-10 19:53:08 -07:00
Hemachandar f7b00e72f1 Implement UnfurlsStore (#8920)
* Implement UnfurlsStore

* simplify lookup

* refetch unfurl after X elapsed time

* compute fetchedAt in client
2025-04-10 18:24:32 -07:00
Hemachandar e499881110 fix: Update collection 'documentStructure' when archived document is deleted (#8922) 2025-04-10 18:11:30 -07:00
Tom Moor 016c8c802c Finalize moving docker publish to GH actions (#8927) 2025-04-10 18:10:10 -07:00
Tom Moor d4bc189e12 fix: collectionIndexing results in teamId undefined error due to Sequelize bug (#8918) 2025-04-09 07:12:48 -07:00
dependabot[bot] 4d435cd5ec chore(deps): bump koa from 2.16.0 to 2.16.1 (#8917)
Bumps [koa](https://github.com/koajs/koa) from 2.16.0 to 2.16.1.
- [Release notes](https://github.com/koajs/koa/releases)
- [Changelog](https://github.com/koajs/koa/blob/master/History.md)
- [Commits](https://github.com/koajs/koa/compare/2.16.0...v2.16.1)

---
updated-dependencies:
- dependency-name: koa
  dependency-version: 2.16.1
  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-04-09 07:12:15 -07:00
Rahma-sbei 59c611b24f Added delay on sidebar exit (#8888)
* added delay on sidebar exit

* Fix typos in Sidebar component comments

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-09 02:44:59 +00:00
Tom Moor 1ea40c03c5 Add option to copy as plain text (#8913) 2025-04-08 19:31:34 -07:00
Tom Moor f9919e90cf fix: Allow OIDC without team name (#8911) 2025-04-08 19:10:07 -07:00
dependabot[bot] 0d09e54757 chore(deps): bump the aws group with 5 updates (#8899)
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.777.0` | `3.782.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.777.0` | `3.782.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.777.0` | `3.782.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.777.0` | `3.782.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.775.0` | `3.782.0` |


Updates `@aws-sdk/client-s3` from 3.777.0 to 3.782.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.782.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.777.0 to 3.782.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.782.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.777.0 to 3.782.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.782.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.777.0 to 3.782.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.782.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.775.0 to 3.782.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.782.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-08 18:00:01 -07:00
Tom Moor 9ce7133837 fix: Increase lock timeout for calculating document diff (#8902) 2025-04-08 17:59:54 -07:00
dependabot[bot] 01a5ff031a chore(deps): bump sonner from 1.7.1 to 1.7.4 (#8896)
Bumps [sonner](https://github.com/emilkowalski/sonner) from 1.7.1 to 1.7.4.
- [Release notes](https://github.com/emilkowalski/sonner/releases)
- [Commits](https://github.com/emilkowalski/sonner/commits)

---
updated-dependencies:
- dependency-name: sonner
  dependency-version: 1.7.4
  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-04-08 10:38:45 -04:00
dependabot[bot] 5659aeb360 chore(deps): bump vite from 5.4.16 to 5.4.17 (#8903)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.16 to 5.4.17.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.17/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.17/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.17
  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-04-08 10:35:10 -04:00
dependabot[bot] d300e34447 chore(deps): bump prosemirror-inputrules from 1.4.0 to 1.5.0 (#8897)
Bumps [prosemirror-inputrules](https://github.com/prosemirror/prosemirror-inputrules) from 1.4.0 to 1.5.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-inputrules/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-inputrules/compare/1.4.0...1.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-07 20:07:38 -07:00
dependabot[bot] a4040a93a2 chore(deps-dev): bump @types/node from 20.17.27 to 20.17.30 (#8898)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.17.27 to 20.17.30.
- [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-version: 20.17.30
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-07 20:07:17 -07:00
dependabot[bot] c769432993 chore(deps): bump mammoth from 1.8.0 to 1.9.0 (#8900)
Bumps [mammoth](https://github.com/mwilliamson/mammoth.js) from 1.8.0 to 1.9.0.
- [Release notes](https://github.com/mwilliamson/mammoth.js/releases)
- [Changelog](https://github.com/mwilliamson/mammoth.js/blob/master/NEWS)
- [Commits](https://github.com/mwilliamson/mammoth.js/compare/1.8.0...1.9.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-07 20:07:07 -07:00
Tom Moor 270bb85417 Various improvements extracted from oauth-server branch (#8901) 2025-04-07 18:40:18 +00:00
Tom Moor fe8e50da92 fix: Remove url->embed mapping in Markdown import (#8891) 2025-04-07 00:43:18 +00:00
codegen-sh[bot] 31d1f566bc #8873: Remove usage of generateAvatarUrl and logo.clearbit.com API (#8889) 2025-04-06 16:01:23 -07:00
121 changed files with 3809 additions and 1617 deletions
+4
View File
@@ -127,6 +127,10 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Linear
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# To configure Discord auth, you'll need to create a Discord Application at
# => https://discord.com/developers/applications/
#
+1 -1
View File
@@ -65,7 +65,7 @@
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["warn", "method"],
"lodash/import-scope": ["error", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
+6 -4
View File
@@ -11,7 +11,7 @@ env:
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
REDIS_URL: redis://127.0.0.1:6379
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
NODE_OPTIONS: --max-old-space-size=8192
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
@@ -63,6 +63,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
@@ -81,7 +82,7 @@ jobs:
- 'yarn.lock'
test:
needs: build
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -143,8 +144,8 @@ jobs:
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types]
if: ${{ needs.changes.outputs.app == 'true' }}
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -161,3 +162,4 @@ jobs:
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
+179 -19
View File
@@ -3,25 +3,32 @@ name: Docker
on:
push:
tags:
- 'v*'
- "v*"
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-and-push:
runs-on: ubuntu-latest
build-arm:
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
@@ -29,24 +36,177 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
uses: docker/build-push-action@v5
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
push: true
tags: ${{ env.BASE_IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push main image
uses: docker/build-push-action@v5
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-amd:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubicloud-standard-8
needs:
- build-amd
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+3 -2
View File
@@ -1,5 +1,6 @@
ARG APP_PATH=/opt/outline
FROM outlinewiki/outline-base AS base
ARG BASE_IMAGE=outlinewiki/outline-base
FROM ${BASE_IMAGE} AS base
ARG APP_PATH
WORKDIR $APP_PATH
@@ -30,7 +31,7 @@ RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
+4 -1
View File
@@ -1,11 +1,14 @@
ARG APP_PATH=/opt/outline
FROM node:20-slim AS deps
FROM node:20 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.82.0
Licensed Work: Outline 0.83.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-02-15
Change Date: 2029-04-11
Change License: Apache License, Version 2.0
+27 -1
View File
@@ -29,6 +29,7 @@ import {
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -510,6 +511,25 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toPlainText());
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
@@ -555,7 +575,12 @@ export const copyDocument = createAction({
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
children: [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
],
});
export const duplicateDocument = createAction({
@@ -1205,6 +1230,7 @@ export const rootDocumentActions = [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
starDocument,
unstarDocument,
publishDocument,
+1 -2
View File
@@ -5,7 +5,6 @@ import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -33,7 +32,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to={logoutPath()} />;
return <Redirect to="/" />;
};
export default observer(Authenticated);
+146 -140
View File
@@ -1,4 +1,5 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
@@ -8,6 +9,7 @@ import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
@@ -23,9 +25,9 @@ const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
/** ID of the unfurl that will be shown in the hover preview. */
unfurlId: string | null;
/** Whether the preview data is being loaded. */
dataLoading: boolean;
/** A callback on close of the hover preview. */
onClose: () => void;
@@ -36,151 +38,155 @@ enum Direction {
DOWN,
}
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const HoverPreviewDesktop = observer(
({ element, unfurlId, dataLoading, onClose }: Props) => {
const { unfurls } = useStores();
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}
}, []);
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
if (dataLoading) {
return <LoadingIndicator />;
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
);
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
@@ -190,7 +196,7 @@ function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
<HoverPreviewDesktop
{...rest}
element={element}
data={data}
unfurlId={unfurlId}
dataLoading={dataLoading}
/>
);
@@ -1,9 +1,13 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import {
IntegrationService,
UnfurlResourceType,
UnfurlResponse,
} from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";
import {
@@ -23,6 +27,11 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
ref: React.Ref<HTMLDivElement>
) {
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -31,7 +40,7 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
<CardContent>
<Flex gap={2} column>
<Title>
<IssueStatusIcon status={state.name} color={state.color} />
<IssueStatusIcon service={service} state={state} />
<span>
{title}&nbsp;<Text type="tertiary">{id}</Text>
</span>
@@ -1,9 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";
import {
+6 -1
View File
@@ -45,6 +45,10 @@ export const NativeInput = styled.input<{
${ellipsis()}
${undraggableOnDesktop()}
&[readOnly] {
color: ${s("textSecondary")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
@@ -126,13 +130,14 @@ export interface Props
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
labelHidden?: boolean;
label?: string;
flex?: boolean;
short?: boolean;
margin?: string | number;
error?: string;
rows?: number;
/** Optional component that appears inside the input before the textarea and any icon */
prefix?: React.ReactNode;
/** Optional icon that appears inside the input before the textarea */
-26
View File
@@ -1,26 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
type Props = {
children?: React.ReactNode;
label: React.ReactNode | string;
};
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
<Label>{label}</Label>
{children}
</Flex>
);
export const Label = styled(Flex)`
font-weight: 500;
padding-bottom: 4px;
display: inline-block;
color: ${s("text")};
`;
export default observer(Labeled);
+3
View File
@@ -114,6 +114,8 @@ const Modal: React.FC<Props> = ({
<Small {...props}>
<Centered
onClick={(ev) => ev.stopPropagation()}
// maxHeight needed for proper overflow behavior in Safari
style={{ maxHeight: "65vh" }}
column
reverse
>
@@ -259,6 +261,7 @@ const Small = styled.div`
width: 75vw;
min-width: 350px;
max-width: 450px;
max-height: 65vh;
z-index: ${depths.modal};
display: flex;
justify-content: center;
+20 -6
View File
@@ -54,6 +54,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const [hasPointerMoved, setPointerMoved] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
@@ -114,6 +116,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerActivity = React.useCallback(() => {
if (ui.sidebarIsClosed) {
// clear the timeout when mouse exits
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
setHovering(document.hasFocus());
setPointerMoved(true);
}
@@ -122,12 +128,20 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerLeave = React.useCallback(
(ev) => {
if (hasPointerMoved) {
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
// clear any previous timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
// add a short delay when mouse exits the sidebar before closing
hoverTimeoutRef.current = setTimeout(() => {
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}, 500);
}
},
[width, hasPointerMoved]
@@ -148,7 +148,12 @@ function InnerDocumentLink(
const color = document?.color || node.color;
// Draggable
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
const [{ isDragging }, drag] = useDragDocument(
node,
depth,
document,
isEditing
);
// Drop to re-parent
const parentRef = React.useRef<HTMLDivElement>(null);
@@ -270,6 +275,8 @@ function InnerDocumentLink(
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
@@ -285,6 +292,7 @@ function InnerDocumentLink(
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
+12 -6
View File
@@ -39,6 +39,7 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
location?: Location;
strict?: boolean;
to: LocationDescriptor;
component?: React.ComponentType;
onBeforeClick?: () => void;
}
@@ -146,17 +147,22 @@ const NavLink = ({
setPreActive(undefined);
}, [currentLocation]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
},
[navigateTo]
);
return (
<Link
key={isActive ? "active" : "inactive"}
ref={linkRef}
onClick={handleClick}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
}}
onKeyDown={handleKeyDown}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -166,11 +166,13 @@ export function useDropToReorderStar(getIndex?: () => string) {
* @param node The NavigationNode model to drag.
* @param depth The depth of the node in the sidebar.
* @param document The related Document model.
* @param isEditing Whether the sidebar item is currently being edited.
*/
export function useDragDocument(
node: NavigationNode,
depth: number,
document?: Document
document?: Document,
isEditing?: boolean
) {
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
@@ -188,7 +190,7 @@ export function useDragDocument(
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
} as DragObject),
canDrag: () => !!document?.isActive,
canDrag: () => !!document?.isActive && !isEditing,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
+3 -1
View File
@@ -335,6 +335,7 @@ const TR = styled.div<{ $columns: string }>`
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
overflow: hidden;
&:last-child {
border-bottom: 0;
@@ -357,7 +358,8 @@ const TD = styled.span`
padding: 10px 6px;
font-size: 14px;
text-wrap: wrap;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
&:first-child {
font-size: 15px;
+1 -71
View File
@@ -1,73 +1,3 @@
import styled, { css } from "styled-components";
import { ellipsis } from "@shared/styles";
type Props = {
/** The type of text to render */
type?: "secondary" | "tertiary" | "danger";
/** The size of the text */
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
/** The direction of the text (defaults to ltr) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the text should be selectable (defaults to false) */
selectable?: boolean;
/** The font weight of the text */
weight?: "xbold" | "bold" | "normal";
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be truncated with an ellipsis */
ellipsis?: boolean;
/** Whether the text should be monospaced */
monospace?: boolean;
};
/**
* Use this component for all interface text that should not be selectable
* by the user, this is the majority of UI text explainers, notes, headings.
*/
const Text = styled.span<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
: props.type === "tertiary"
? props.theme.textTertiary
: props.type === "danger"
? props.theme.brand.red
: props.theme.text};
font-size: ${(props) =>
props.size === "xlarge"
? "26px"
: props.size === "large"
? "18px"
: props.size === "medium"
? "16px"
: props.size === "small"
? "14px"
: props.size === "xsmall"
? "13px"
: "inherit"};
${(props) =>
props.weight &&
css`
font-weight: ${props.weight === "xbold"
? 600
: props.weight === "bold"
? 500
: props.weight === "normal"
? 400
: "inherit"};
`}
font-style: ${(props) => (props.italic ? "italic" : "normal")};
font-family: ${(props) =>
props.monospace ? props.theme.fontFamilyMono : "inherit"};
white-space: normal;
user-select: ${(props) => (props.selectable ? "text" : "none")};
${(props) => props.ellipsis && ellipsis()}
`;
import Text from "@shared/components/Text";
export default Text;
+59 -24
View File
@@ -1,7 +1,15 @@
import { LinkIcon } from "outline-icons";
import { observer } from "mobx-react";
import { EmailIcon, LinkIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { determineMentionType, isURLMentionable } from "~/utils/mention";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
@@ -15,34 +23,65 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
const PasteMenu = ({ embeds, ...props }: Props) => {
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
let mentionType: MentionType | undefined;
const url = pastedText ? new URL(pastedText) : undefined;
if (url) {
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
mentionType = integration
? determineMentionType({ url, integration })
: undefined;
}
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(props.pastedText);
const matches = e.matcher(pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, props.pastedText]);
}, [embeds, pastedText]);
const items = React.useMemo(
() => [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
() =>
[
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "mention",
title: t("Mention"),
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: v4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: v4(),
actorId: user?.id,
},
appendSpace: true,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
] satisfies MenuItem[],
[t, embed, mentionType, pastedText, user]
);
return (
@@ -52,9 +91,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={() => {
props.onSelect?.(item);
}}
onClick={options.onClick}
selected={options.selected}
title={item.title}
icon={item.icon}
@@ -63,6 +100,4 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
items={items}
/>
);
};
export default PasteMenu;
});
+1 -1
View File
@@ -174,12 +174,12 @@ export default function SelectionToolbar(props: Props) {
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
if ((readOnly && !canComment) || isDragging) {
return null;
}
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
@@ -1,5 +1,6 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { isList } from "@shared/editor/queries/isList";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
/**
@@ -18,17 +19,25 @@ export default class ClipboardTextSerializer extends Extension {
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice) => {
clipboardTextSerializer: (slice, view) => {
const isMultiline = slice.content.childCount > 1;
// This is a cheap way to determine if the content is "complex",
// aka it has multiple marks or formatting. In which case we'll use
// markdown formatting
const hasMultipleListItems = slice.content.content
.filter((node) => node.content.content.length > 1)
.some((node) => isList(node, view.state.schema));
const hasMultipleBlockTypes =
[
...new Set(
slice.content.content
.filter((node) => node.content.content.length > 1)
.map((node) => node.type.name)
),
].length > 1;
const copyAsMarkdown =
isMultiline ||
slice.content.content.some(
(node) => node.content.content.length > 1
);
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
+20 -13
View File
@@ -4,9 +4,9 @@ import { EditorView } from "prosemirror-view";
import * as React from "react";
import Extension from "@shared/editor/lib/Extension";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import stores from "~/stores";
import HoverPreview from "~/components/HoverPreview";
import env from "~/env";
import { client } from "~/utils/ApiClient";
interface HoverPreviewsOptions {
/** Delay before the target is considered "hovered" and callback is triggered. */
@@ -16,11 +16,11 @@ interface HoverPreviewsOptions {
export default class HoverPreviews extends Extension {
state: {
activeLinkElement: HTMLElement | null;
data: Record<string, any> | null;
unfurlId: string | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
data: null,
unfurlId: null,
dataLoading: false,
});
@@ -62,19 +62,25 @@ export default class HoverPreviews extends Extension {
);
if (url) {
const transformedUrl = url.startsWith("/")
? env.URL + url
: url;
this.state.dataLoading = true;
try {
const data = await client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId,
});
const unfurl = await stores.unfurls.fetchUnfurl({
url: transformedUrl,
documentId,
});
if (unfurl) {
this.state.activeLinkElement = element;
this.state.data = data;
} catch (err) {
this.state.unfurlId = transformedUrl;
} else {
this.state.activeLinkElement = null;
} finally {
this.state.dataLoading = false;
}
this.state.dataLoading = false;
}
}),
this.options.delay
@@ -101,10 +107,11 @@ export default class HoverPreviews extends Extension {
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
data={this.state.data}
unfurlId={this.state.unfurlId}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
this.state.unfurlId = null;
})}
/>
);
+21 -1
View File
@@ -24,7 +24,7 @@ import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
import { PasteMenu } from "../components/PasteMenu";
export default class PasteHandler extends Extension {
state: {
@@ -415,6 +415,21 @@ export default class PasteHandler extends Extension {
});
};
private insertMention = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
// Remove just the placeholder here.
// Mention node will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private removePlaceholder = () => {
const { view } = this.editor;
const { state } = view;
@@ -450,6 +465,11 @@ export default class PasteHandler extends Extension {
this.insertEmbed();
break;
}
case "mention": {
this.hidePasteMenu();
this.insertMention();
break;
}
default:
break;
}
+2 -18
View File
@@ -1,19 +1,3 @@
import * as React from "react";
import useIsMounted from "@shared/hooks/useIsMounted";
/**
* Hook to check if component is still mounted
*
* @returns {boolean} true if the component is mounted, false otherwise
*/
export default function useIsMounted() {
const isMounted = React.useRef(false);
React.useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return React.useCallback(() => isMounted.current, []);
}
export default useIsMounted;
-10
View File
@@ -8,7 +8,6 @@ import {
GlobeIcon,
TeamIcon,
BeakerIcon,
BuildingBlocksIcon,
SettingsIcon,
ExportIcon,
ImportIcon,
@@ -40,7 +39,6 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
@@ -177,14 +175,6 @@ const useSettingsConfig = () => {
icon: ExportIcon,
},
// Integrations
{
name: t("Self Hosted"),
path: integrationSettingsPath("self-hosted"),
component: SelfHosted,
enabled: can.update && !isCloudHosted,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
+19
View File
@@ -17,6 +17,7 @@ import {
NavigationNodeType,
NotificationEventType,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Storage from "@shared/utils/Storage";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
@@ -663,6 +664,24 @@ export default class Document extends ArchivableModel implements Searchable {
return markdown;
};
/**
* Returns the plain text representation of the document derived from the ProseMirror data.
*
* @returns The plain text representation of the document as a string.
*/
toPlainText = () => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const text = ProsemirrorHelper.toPlainText(
Node.fromJSON(schema, this.data),
schema
);
return text;
};
download = (contentType: ExportContentType) =>
client.post(
`/documents.export`,
+3 -3
View File
@@ -1,8 +1,8 @@
import { observable } from "mobx";
import type {
import {
IntegrationService,
IntegrationSettings,
IntegrationType,
type IntegrationSettings,
type IntegrationType,
} from "@shared/types";
import User from "~/models/User";
import Model from "~/models/base/Model";
+18
View File
@@ -0,0 +1,18 @@
import { observable } from "mobx";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Model from "./base/Model";
class Unfurl<UnfurlType extends UnfurlResourceType> extends Model {
static modelName = "Unfurl";
@observable
type: UnfurlType;
@observable
data: UnfurlResponse[UnfurlType];
@observable
fetchedAt: string;
}
export default Unfurl;
+2
View File
@@ -209,7 +209,9 @@ function Invite({ onSubmit }: Props) {
placeholder={`name@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoComplete="off"
autoFocus
data-1p-ignore
flex
/>
<StyledInput
+4 -1
View File
@@ -39,7 +39,10 @@ export function LoginDialog() {
maxLength={255}
autoComplete="off"
placeholder={t("subdomain")}
{...register("subdomain", { required: true, pattern: /^[a-z\d-]+$/ })}
{...register("subdomain", {
required: true,
pattern: /^[a-z\d-]{1,63}$/,
})}
>
<Domain>.getoutline.com</Domain>
</Input>
+10 -1
View File
@@ -3,6 +3,15 @@ import { parseDomain } from "@shared/utils/domains";
import env from "~/env";
import Desktop from "~/utils/Desktop";
function validateAndEncodeSubdomain(subdomain: string): string {
const encodedSubdomain = encodeURIComponent(subdomain);
const urlPattern = /^[a-z\d-]{1,63}$/;
if (!urlPattern.test(encodedSubdomain)) {
throw new Error("Invalid subdomain");
}
return `https://${encodedSubdomain}.getoutline.com`;
}
/**
* If we're on a custom domain or a subdomain then the auth must point to the
* apex (env.URL) for authentication so that the state cookie can be set and read.
@@ -36,7 +45,7 @@ export async function navigateToSubdomain(subdomain: string) {
.toLowerCase()
.trim()
.replace(/^https?:\/\//, "");
const host = `https://${normalizedSubdomain}.getoutline.com`;
const host = validateAndEncodeSubdomain(normalizedSubdomain);
await Desktop.bridge?.addCustomHost(host);
window.location.href = host;
}
-140
View File
@@ -1,140 +0,0 @@
import find from "lodash/find";
import { observer } from "mobx-react";
import { BuildingBlocksIcon } from "outline-icons";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { IntegrationService, IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import useStores from "~/hooks/useStores";
import SettingRow from "./components/SettingRow";
type FormData = {
drawIoUrl: string;
gristUrl: string;
};
function SelfHosted() {
const { integrations } = useStores();
const { t } = useTranslation();
const integrationDiagrams = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
}) as Integration<IntegrationType.Embed> | undefined;
const integrationGrist = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Grist,
}) as Integration<IntegrationType.Embed> | undefined;
const {
register,
reset,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>({
mode: "all",
defaultValues: {
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Embed,
});
}, [integrations]);
React.useEffect(() => {
reset({
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
});
}, [integrationDiagrams, integrationGrist, reset]);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
if (data.drawIoUrl) {
await integrations.save({
id: integrationDiagrams?.id,
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
settings: {
url: data.drawIoUrl,
},
});
} else {
await integrationDiagrams?.delete();
}
if (data.gristUrl) {
await integrations.save({
id: integrationGrist?.id,
type: IntegrationType.Embed,
service: IntegrationService.Grist,
settings: {
url: data.gristUrl,
},
});
} else {
await integrationGrist?.delete();
}
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
},
[integrations, integrationDiagrams, integrationGrist, t]
);
return (
<Scene title={t("Self Hosted")} icon={<BuildingBlocksIcon />}>
<Heading>{t("Self Hosted")}</Heading>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Draw.io deployment")}
name="drawIoUrl"
description={t(
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
)}
border={false}
>
<Input
placeholder="https://app.diagrams.net/"
pattern="https?://.*"
{...register("drawIoUrl")}
/>
</SettingRow>
<SettingRow
label={t("Grist deployment")}
name="gristUrl"
description={t("Add your self-hosted grist installation URL here.")}
border={false}
>
<Input
placeholder="https://docs.getgrist.com/"
pattern="https?://.*"
{...register("gristUrl")}
/>
</SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
}
export default observer(SelfHosted);
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Spinner from "@shared/components/Spinner";
import {
FileOperationFormat,
FileOperationState,
@@ -13,7 +14,6 @@ import FileOperation from "~/models/FileOperation";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
+155 -125
View File
@@ -1,19 +1,18 @@
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useState, useRef } from "react";
import AvatarEditor from "react-avatar-editor";
import Dropzone from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { AttachmentValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
import Button from "~/components/Button";
import ButtonLarge from "~/components/ButtonLarge";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import useStores from "~/hooks/useStores";
import { compressImage } from "~/utils/compressImage";
import { uploadFile, dataUrlToBlob } from "~/utils/files";
@@ -24,132 +23,167 @@ export type Props = {
borderRadius?: number;
};
@observer
class ImageUpload extends React.Component<RootStore & Props> {
@observable
isUploading = false;
const ImageUpload: React.FC<Props> = ({
onSuccess,
onError,
submitText,
borderRadius,
children,
}) => {
const { dialogs } = useStores();
const { t } = useTranslation();
@observable
isCropping = false;
const [isUploading, setIsUploading] = useState(false);
const [isCropping, setIsCropping] = useState(false);
@observable
zoom = 1;
const uploadImage = React.useCallback(
async (blob: Blob, file: File) => {
try {
const compressed = await compressImage(blob, {
maxHeight: 512,
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: file.name,
preset: AttachmentPreset.Avatar,
});
void onSuccess(attachment.url);
} catch (err) {
onError(err.message);
} finally {
setIsUploading(false);
setIsCropping(false);
dialogs.closeAllModals();
}
},
[dialogs, onSuccess, onError]
);
@observable
file: File;
const handleUpload = React.useCallback(
(blob: Blob, file: File) => {
setIsUploading(true);
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(() => uploadImage(blob, file), 0);
},
[uploadImage]
);
avatarEditorRef = React.createRef<AvatarEditor>();
const handleClose = React.useCallback(() => {
setIsUploading(false);
setIsCropping(false);
}, []);
static defaultProps = {
submitText: "Crop Image",
borderRadius: 150,
};
onDropAccepted = async (files: File[]) => {
this.isCropping = true;
this.file = files[0];
};
handleCrop = () => {
this.isUploading = true;
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(this.uploadImage, 0);
};
uploadImage = async () => {
const canvas = this.avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const imageBlob = dataUrlToBlob(canvas.toDataURL());
try {
const compressed = await compressImage(imageBlob, {
maxHeight: 512,
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: this.file.name,
preset: AttachmentPreset.Avatar,
});
void this.props.onSuccess(attachment.url);
} catch (err) {
this.props.onError(err.message);
} finally {
this.isUploading = false;
this.isCropping = false;
}
};
handleClose = () => {
this.isUploading = false;
this.isCropping = false;
};
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
this.zoom = parseFloat(target.value);
}
};
renderCropping() {
const { ui, submitText } = this.props;
return (
<Modal isOpen onRequestClose={this.handleClose} title="">
<Flex auto column align="center" justify="center">
{this.isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={this.avatarEditorRef}
image={this.file}
width={250}
height={250}
border={25}
borderRadius={this.props.borderRadius}
color={
ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]
} // RGBA
scale={this.zoom}
rotate={0}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={this.handleZoom}
const onDropAccepted = React.useCallback(
async (files: File[]) => {
setIsCropping(true);
dialogs.openModal({
title: "",
content: (
<AvatarEditorDialog
file={files[0]}
onUpload={handleUpload}
isUploading={isUploading}
borderRadius={borderRadius ?? 150}
submitText={submitText || t("Crop image")}
/>
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
{this.isUploading ? "Uploading…" : submitText}
</CropButton>
</Flex>
</Modal>
);
),
onClose: handleClose,
});
},
[
t,
dialogs,
handleUpload,
handleClose,
isUploading,
borderRadius,
submitText,
]
);
const { getRootProps, getInputProps } = useDropzone({
accept: AttachmentValidation.avatarContentTypes.join(", "),
onDropAccepted,
});
if (isCropping) {
return null; // onDropAccepted would have opened a modal for cropping the image.
}
render() {
if (this.isCropping) {
return this.renderCropping();
}
return (
<div {...getRootProps()}>
<input {...getInputProps()} />
{children}
</div>
);
};
type AvatarEditorDialogProps = {
file: File;
onUpload: (blob: Blob, file: File) => void;
isUploading: boolean;
borderRadius: number;
submitText: string;
};
const AvatarEditorDialog: React.FC<AvatarEditorDialogProps> = observer(
({ file, onUpload, isUploading, borderRadius, submitText }) => {
const { ui } = useStores();
const { t } = useTranslation();
const [zoom, setZoom] = useState(1);
const avatarEditorRef = useRef<AvatarEditor>(null);
const handleUpload = React.useCallback(() => {
const canvas = avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const blob = dataUrlToBlob(canvas.toDataURL());
onUpload(blob, file);
}, [file, onUpload]);
const handleZoom = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
setZoom(parseFloat(target.value));
}
},
[]
);
return (
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} />
{this.props.children}
</div>
)}
</Dropzone>
<Flex auto column align="center" justify="center">
{isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={avatarEditorRef}
image={file}
width={250}
height={250}
border={25}
borderRadius={borderRadius}
color={ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]} // RGBA
scale={zoom}
rotate={0}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={handleZoom}
/>
<br />
<ButtonLarge fullwidth onClick={handleUpload} disabled={isUploading}>
{isUploading ? `${t(`Uploading`)}` : submitText}
</ButtonLarge>
</Flex>
);
}
}
);
const AvatarEditorContainer = styled(Flex)`
margin-bottom: 30px;
@@ -180,8 +214,4 @@ const RangeInput = styled.input`
}
`;
const CropButton = styled(Button)`
width: 300px;
`;
export default withStores(ImageUpload);
export default observer(ImageUpload);
@@ -5,12 +5,12 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Spinner from "@shared/components/Spinner";
import { ImportState } from "@shared/types";
import Import from "~/models/Import";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
+28 -16
View File
@@ -2,6 +2,7 @@ import compact from "lodash/compact";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Text from "@shared/components/Text";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
@@ -14,6 +15,7 @@ import {
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import UserMenu from "~/menus/UserMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
@@ -27,6 +29,7 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
export function MembersTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const isMobile = useMobile();
const columns = React.useMemo<TableColumn<User>[]>(
() =>
@@ -38,13 +41,20 @@ export function MembersTable({ canManage, ...rest }: Props) {
accessor: (user) => user.name,
component: (user) => (
<Flex align="center" gap={8}>
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
{currentUser.id === user.id && `(${t("You")})`}
<Avatar model={user} size={AvatarSize.Large} />{" "}
<Flex column>
<Text>
{user.name} {currentUser.id === user.id && `(${t("You")})`}
</Text>
{isMobile && canManage && (
<Text type="tertiary">{user.email}</Text>
)}
</Flex>
</Flex>
),
width: "4fr",
},
canManage
canManage && !isMobile
? {
type: "data",
id: "email",
@@ -54,17 +64,19 @@ export function MembersTable({ canManage, ...rest }: Props) {
width: "4fr",
}
: undefined,
{
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
isMobile
? undefined
: {
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "role",
@@ -85,7 +97,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
),
width: "2fr",
width: "80px",
},
canManage
? {
@@ -97,7 +109,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
}
: undefined,
]),
[t, currentUser, canManage]
[t, currentUser, canManage, isMobile]
);
return (
+1 -1
View File
@@ -306,7 +306,7 @@ export default class AuthStore extends Store<Team> {
// if this logout was forced from an authenticated route then
// save the current path so we can go back there once signed in
if (savePath) {
setPostLoginPath(window.location.pathname);
setPostLoginPath(window.location.pathname + window.location.search);
}
if (tryRevokingToken) {
+7
View File
@@ -21,6 +21,13 @@ class IntegrationsStore extends Store<Integration> {
(integration) => integration.service === IntegrationService.GitHub
);
}
@computed
get linear(): Integration<IntegrationType.Embed>[] {
return this.orderedData.filter(
(integration) => integration.service === IntegrationService.Linear
);
}
}
export default IntegrationsStore;
+3
View File
@@ -26,6 +26,7 @@ import SharesStore from "./SharesStore";
import StarsStore from "./StarsStore";
import SubscriptionsStore from "./SubscriptionsStore";
import UiStore from "./UiStore";
import UnfurlsStore from "./UnfurlsStore";
import UserMembershipsStore from "./UserMembershipsStore";
import UsersStore from "./UsersStore";
import ViewsStore from "./ViewsStore";
@@ -55,6 +56,7 @@ export default class RootStore {
searches: SearchesStore;
shares: SharesStore;
ui: UiStore;
unfurls: UnfurlsStore;
stars: StarsStore;
subscriptions: SubscriptionsStore;
users: UsersStore;
@@ -85,6 +87,7 @@ export default class RootStore {
this.registerStore(SharesStore);
this.registerStore(StarsStore);
this.registerStore(SubscriptionsStore);
this.registerStore(UnfurlsStore);
this.registerStore(UsersStore);
this.registerStore(ViewsStore);
this.registerStore(FileOperationsStore);
+85
View File
@@ -0,0 +1,85 @@
import { subMinutes } from "date-fns";
import { action } from "mobx";
import { UnfurlResourceType } from "@shared/types";
import Unfurl from "~/models/Unfurl";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import RootStore from "./RootStore";
import Store from "./base/Store";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class UnfurlsStore extends Store<Unfurl<any>> {
actions = []; // no default actions allowed for unfurls.
constructor(rootStore: RootStore) {
super(rootStore, Unfurl);
}
fetchUnfurl = async <UnfurlType extends UnfurlResourceType>({
url,
documentId,
}: {
url: string;
documentId?: string;
}): Promise<Unfurl<UnfurlType> | undefined> => {
const unfurl = this.get(url);
if (unfurl) {
this.refetch({ unfurl: unfurl as Unfurl<UnfurlType>, documentId });
return unfurl;
}
return this.unfurl<UnfurlType>({ url, documentId });
};
private refetch = <UnfurlType extends UnfurlResourceType>({
unfurl,
documentId,
}: {
unfurl: Unfurl<UnfurlType>;
documentId?: string;
}) => {
const fiveMinutesAgo = subMinutes(new Date(), 5);
if (new Date(unfurl.fetchedAt) < fiveMinutesAgo) {
void this.unfurl({ url: unfurl.id, documentId });
}
};
@action
private unfurl = async <UnfurlType extends UnfurlResourceType>({
url,
documentId,
}: {
url: string;
documentId?: string;
}): Promise<Unfurl<UnfurlType> | undefined> => {
try {
this.isFetching = true;
const data = await client.post("/urls.unfurl", {
url,
documentId,
});
// unfurls can succeed with no data.
if (!data) {
return;
}
return this.add({
id: url,
type: data.type,
fetchedAt: new Date().toISOString(),
data,
} as Unfurl<UnfurlType>);
} catch (err) {
Logger.error(`Failed to unfurl url ${url}`, err);
return;
} finally {
this.isFetching = false;
}
};
}
export default UnfurlsStore;
+9 -1
View File
@@ -1,5 +1,6 @@
import commandScore from "command-score";
import invariant from "invariant";
// eslint-disable-next-line lodash/import-scope
import type { ObjectIterateeCustom } from "lodash";
import deburr from "lodash/deburr";
import filter from "lodash/filter";
@@ -98,7 +99,14 @@ export default abstract class Store<T extends Model> {
const normalized = deburr((query ?? "").trim().toLocaleLowerCase());
if (!normalized) {
return this.orderedData.slice(0, options?.maxResults);
return this.orderedData
.filter((item) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
return true;
})
.slice(0, options?.maxResults);
}
return this.orderedData
+73
View File
@@ -0,0 +1,73 @@
import {
IntegrationService,
IntegrationSettings,
IntegrationType,
MentionType,
} from "@shared/types";
import Integration from "~/models/Integration";
export const isURLMentionable = ({
url,
integration,
}: {
url: URL;
integration: Integration;
}): boolean => {
const { hostname, pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "github.com" &&
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
);
}
case IntegrationService.Linear: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "linear.app" &&
settings.linear?.workspace.key === pathParts[1] // ensure installed workspace key matches with the provided url.
);
}
default:
return false;
}
};
export const determineMentionType = ({
url,
integration,
}: {
url: URL;
integration: Integration;
}): MentionType | undefined => {
const { pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const type = pathParts[3];
return type === "pull"
? MentionType.PullRequest
: type === "issues"
? MentionType.Issue
: undefined;
}
case IntegrationService.Linear: {
const type = pathParts[2];
return type === "issue" ? MentionType.Issue : undefined;
}
default:
return;
}
};
+5 -3
View File
@@ -49,8 +49,10 @@ export function redirectTo(url: string) {
/**
* Check if the path is a valid path for redirect after login.
*
* @param path
* @param input A path potentially including query string
* @returns boolean indicating if the path is a valid redirect
*/
export const isAllowedLoginRedirect = (path: string) =>
!["/", "/create", "/home", "/logout", "/auth/"].includes(path);
export const isAllowedLoginRedirect = (input: string) => {
const path = input.split("?")[0];
return !["/", "/create", "/home", "/logout", "/auth/"].includes(path);
};
+25 -23
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.777.0",
"@aws-sdk/lib-storage": "3.777.0",
"@aws-sdk/s3-presigned-post": "3.777.0",
"@aws-sdk/s3-request-presigner": "3.777.0",
"@aws-sdk/signature-v4-crt": "^3.775.0",
"@aws-sdk/client-s3": "3.787.0",
"@aws-sdk/lib-storage": "3.787.0",
"@aws-sdk/s3-presigned-post": "3.787.0",
"@aws-sdk/s3-request-presigner": "3.787.0",
"@aws-sdk/signature-v4-crt": "^3.787.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
@@ -79,12 +79,13 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@notionhq/client": "^2.2.16",
"@linear/sdk": "^39.0.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-visually-hidden": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.2.0",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
@@ -114,7 +115,7 @@
"date-fns": "^3.6.0",
"dd-trace": "^5.40.0",
"diff": "^5.2.0",
"dotenv": "^16.4.7",
"dotenv": "^16.5.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
@@ -141,7 +142,7 @@
"jszip": "^3.10.1",
"katex": "^0.16.21",
"kbar": "0.1.0-beta.41",
"koa": "^2.15.4",
"koa": "^2.16.1",
"koa-body": "^6.0.1",
"koa-compress": "^5.1.1",
"koa-helmet": "^6.1.0",
@@ -153,7 +154,7 @@
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
"mailparser": "^3.7.2",
"mammoth": "^1.8.0",
"mammoth": "^1.9.0",
"markdown-it": "^13.0.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
@@ -179,19 +180,19 @@
"png-chunks-extract": "^1.0.0",
"polished": "^4.3.1",
"prosemirror-codemark": "^0.4.2",
"prosemirror-commands": "^1.7.0",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-inputrules": "^1.5.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.2",
"prosemirror-model": "^1.25.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.38.1",
"prosemirror-view": "^1.39.1",
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
@@ -210,7 +211,7 @@
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
"react-virtualized-auto-sizer": "^1.0.21",
"react-virtualized-auto-sizer": "^1.0.26",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.11",
"reakit": "^1.3.11",
@@ -219,7 +220,7 @@
"refractor": "^3.6.0",
"request-filtering-agent": "^1.1.2",
"resolve-path": "^1.4.0",
"rfc6902": "^5.1.1",
"rfc6902": "^5.1.2",
"sanitize-filename": "^1.6.3",
"scroll-into-view-if-needed": "^3.1.0",
"semver": "^7.7.1",
@@ -232,7 +233,7 @@
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"socket.io-redis": "^6.1.1",
"sonner": "^1.7.1",
"sonner": "^1.7.4",
"stoppable": "^1.1.0",
"string-replace-to-array": "^2.1.1",
"styled-components": "^5.3.11",
@@ -248,8 +249,8 @@
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^5.4.16",
"vite-plugin-pwa": "^0.20.3",
"vite": "^5.4.18",
"vite-plugin-pwa": "^0.21.2",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
@@ -296,7 +297,7 @@
"@types/markdown-it-emoji": "^2.0.4",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.17.27",
"@types/node": "20.17.30",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
@@ -362,7 +363,7 @@
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.3",
"terser": "^5.39.0",
"typescript": "^5.7.3",
"typescript": "^5.8.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
@@ -377,5 +378,6 @@
"rollup": "^4.5.1",
"prismjs": "1.30.0"
},
"version": "0.82.0"
}
"version": "0.83.0",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+1 -1
View File
@@ -94,7 +94,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
/** Default user and team names metadata */
let userName = profile.username;
let teamName = "Wiki";
let teamName;
let userAvatarUrl: string = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`;
let teamAvatarUrl: string | undefined = undefined;
let subdomain = slugifyDomain(domain);
@@ -0,0 +1,40 @@
import { Endpoints } from "@octokit/types";
import { IssueSource } from "@shared/schema";
import { IntegrationService, IntegrationType } from "@shared/types";
import { Integration } from "@server/models";
import { BaseIssueProvider } from "@server/utils/BaseIssueProvider";
import { GitHub } from "./github";
// This is needed to handle Octokit paginate response type mismatch.
type ReposForInstallation =
Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"];
export class GitHubIssueProvider extends BaseIssueProvider {
constructor() {
super(IntegrationService.GitHub);
}
async fetchSources(
integration: Integration<IntegrationType.Embed>
): Promise<IssueSource[]> {
const client = await GitHub.authenticateAsInstallation(
integration.settings.github!.installation.id
);
const sources: IssueSource[] = [];
for await (const response of client.requestRepos()) {
const repos = response.data as unknown as ReposForInstallation;
sources.push(
...repos.map<IssueSource>((repo) => ({
id: String(repo.id),
name: repo.name,
owner: { id: String(repo.owner.id), name: repo.owner.login },
service: IntegrationService.GitHub,
}))
);
}
return sources;
}
}
+18 -20
View File
@@ -2,6 +2,7 @@ import Router from "koa-router";
import find from "lodash/find";
import { IntegrationService, IntegrationType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { createContext } from "@server/context";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
@@ -88,30 +89,27 @@ router.get(
},
{ transaction }
);
await Integration.create(
{
service: IntegrationService.GitHub,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
github: {
installation: {
id: installationId!,
account: {
id: installation.account?.id,
name:
// @ts-expect-error Property 'login' does not exist on type
installation.account?.login,
avatarUrl: installation.account?.avatar_url,
},
await Integration.createWithCtx(createContext({ user, transaction }), {
service: IntegrationService.GitHub,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
github: {
installation: {
id: installationId!,
account: {
id: installation.account?.id,
name:
// @ts-expect-error Property 'login' does not exist on type
installation.account?.login,
avatarUrl: installation.account?.avatar_url,
},
},
},
},
{ transaction }
);
});
ctx.redirect(GitHubUtils.url);
}
);
+94 -23
View File
@@ -4,36 +4,55 @@ import {
type OAuthWebFlowAuthOptions,
type InstallationAuthOptions,
} from "@octokit/auth-app";
import { Endpoints, OctokitResponse } from "@octokit/types";
import { Octokit } from "octokit";
import pluralize from "pluralize";
import {
IntegrationService,
IntegrationType,
JSONObject,
UnfurlResourceType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration, User } from "@server/models";
import { UnfurlSignature } from "@server/types";
import { UnfurlIssueAndPR, UnfurlSignature } from "@server/types";
import { GitHubUtils } from "../shared/GitHubUtils";
import env from "./env";
type PR =
Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"];
type Issue =
Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"];
const requestPlugin = (octokit: Octokit) => ({
requestPR: async (params: ReturnType<typeof GitHub.parseUrl>) =>
octokit.request(`GET /repos/{owner}/{repo}/pulls/{id}`, {
owner: params?.owner,
repo: params?.repo,
id: params?.id,
requestRepos: () =>
octokit.paginate.iterator(
octokit.rest.apps.listReposAccessibleToInstallation,
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}
),
requestPR: async (params: NonNullable<ReturnType<typeof GitHub.parseUrl>>) =>
octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, {
owner: params.owner,
repo: params.repo,
pull_number: params.id,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}),
requestIssue: async (params: ReturnType<typeof GitHub.parseUrl>) =>
octokit.request(`GET /repos/{owner}/{repo}/issues/{id}`, {
owner: params?.owner,
repo: params?.repo,
id: params?.id,
requestIssue: async (
params: NonNullable<ReturnType<typeof GitHub.parseUrl>>
) =>
octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, {
owner: params.owner,
repo: params.repo,
issue_number: params.id,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -56,14 +75,14 @@ const requestPlugin = (octokit: Octokit) => ({
*/
requestResource: async function requestResource(
resource: ReturnType<typeof GitHub.parseUrl>
): Promise<{ data?: JSONObject }> {
): Promise<OctokitResponse<Issue | PR> | undefined> {
switch (resource?.type) {
case UnfurlResourceType.PR:
return this.requestPR(resource);
return this.requestPR(resource) as Promise<OctokitResponse<PR>>;
case UnfurlResourceType.Issue:
return this.requestIssue(resource);
return this.requestIssue(resource) as Promise<OctokitResponse<Issue>>;
default:
return { data: undefined };
return;
}
},
@@ -91,7 +110,10 @@ export class GitHub {
private static appOctokit: Octokit;
private static supportedResources = Object.values(UnfurlResourceType);
private static supportedResources = [
UnfurlResourceType.Issue,
UnfurlResourceType.PR,
];
/**
* Parses a given URL and returns resource identifiers for GitHub specific URLs
@@ -111,7 +133,7 @@ export class GitHub {
const type = parts[3]
? (pluralize.singular(parts[3]) as UnfurlResourceType)
: undefined;
const id = parts[4];
const id = Number(parts[4]);
if (!type || !GitHub.supportedResources.includes(type)) {
return;
@@ -204,14 +226,63 @@ export class GitHub {
const client = await GitHub.authenticateAsInstallation(
integration.settings.github!.installation.id
);
const { data } = await client.requestResource(resource);
if (!data) {
return;
const res = await client.requestResource(resource);
if (!res) {
return { error: "Resource not found" };
}
return { ...data, type: resource.type };
return GitHub.transformData(res.data, resource.type);
} catch (err) {
Logger.warn("Failed to fetch resource from GitHub", err);
return;
return { error: err.message || "Unknown error" };
}
};
private static transformData(data: Issue | PR, type: UnfurlResourceType) {
if (type === UnfurlResourceType.Issue) {
const issue = data as Issue;
return {
type: UnfurlResourceType.Issue,
url: issue.html_url,
id: `#${issue.number}`,
title: issue.title,
description: issue.body_text ?? null,
author: {
name: issue.user?.login ?? "",
avatarUrl: issue.user?.avatar_url ?? "",
},
labels: issue.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: issue.state,
color: GitHubUtils.getColorForStatus(issue.state),
},
createdAt: issue.created_at,
transformed_unfurl: true,
} satisfies UnfurlIssueAndPR;
}
const pr = data as PR;
const prState = pr.merged ? "merged" : pr.state;
return {
type: UnfurlResourceType.PR,
url: pr.html_url,
id: `#${pr.number}`,
title: pr.title,
description: pr.body,
author: {
name: pr.user.login,
avatarUrl: pr.user.avatar_url,
},
state: {
name: prState,
color: GitHubUtils.getColorForStatus(prState),
},
createdAt: pr.created_at,
transformed_unfurl: true,
} satisfies UnfurlIssueAndPR;
}
}
+5
View File
@@ -1,6 +1,7 @@
import { Minute } from "@shared/utils/time";
import { PluginManager, Hook } from "@server/utils/PluginManager";
import config from "../plugin.json";
import { GitHubIssueProvider } from "./GitHubIssueProvider";
import router from "./api/github";
import env from "./env";
import { GitHub } from "./github";
@@ -20,6 +21,10 @@ if (enabled) {
type: Hook.API,
value: router,
},
{
type: Hook.IssueProvider,
value: new GitHubIssueProvider(),
},
{
type: Hook.UnfurlProvider,
value: { unfurl: GitHub.unfurl, cacheExpiry: Minute.seconds },
+6 -4
View File
@@ -1,6 +1,6 @@
import { JSONObject, UnfurlResourceType } from "@shared/types";
import Logger from "@server/logging/Logger";
import { UnfurlSignature } from "@server/types";
import { UnfurlError, UnfurlSignature } from "@server/types";
import fetch from "@server/utils/fetch";
import env from "./env";
@@ -10,7 +10,7 @@ class Iframely {
public static async requestResource(
url: string,
type = "oembed"
): Promise<JSONObject | undefined> {
): Promise<JSONObject | UnfurlError> {
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
// Cloud Iframely requires /api path, while self-hosted does not.
@@ -25,7 +25,7 @@ class Iframely {
return await res.json();
} catch (err) {
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
return;
return { error: err.message || "Unknown error" };
}
}
@@ -36,7 +36,9 @@ class Iframely {
*/
public static unfurl: UnfurlSignature = async (url: string) => {
const data = await Iframely.requestResource(url);
return { ...data, type: UnfurlResourceType.OEmbed };
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
? ({ error: data.error } as UnfurlError)
: { ...data, type: UnfurlResourceType.OEmbed };
};
}
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
d="M3.93091 12.8481C4.11753 14.6298 4.89358 16.3615 6.25902 17.727C7.62446 19.0923 9.35612 19.8684 11.1378 20.0551L3.93091 12.8481Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M3.89929 11.5437L12.4422 20.0865C13.1671 20.0459 13.8876 19.9084 14.5827 19.6738L4.31194 9.4032C4.07743 10.0982 3.93988 10.8187 3.89929 11.5437Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M4.67981 8.49828L15.4875 19.306C16.0482 19.0374 16.5845 18.7005 17.0837 18.2953L5.6905 6.90222C5.28537 7.40142 4.94847 7.93759 4.67981 8.49828Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M6.29602 6.23494C9.46213 3.10852 14.5632 3.12079 17.7141 6.27173C20.865 9.42266 20.8774 14.5237 17.7509 17.6898L6.29602 6.23494Z"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import LinearIcon from "./Icon";
import { LinearConnectButton } from "./components/LinearButton";
function Linear() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const appName = env.APP_NAME;
React.useEffect(() => {
void integrations.fetchAll({
service: IntegrationService.Linear,
withRelations: true,
});
}, [integrations]);
return (
<Scene title="Linear" icon={<LinearIcon />}>
<Heading>Linear</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in Linear to connect{" "}
{{ appName }} to your workspace. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{env.LINEAR_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Enable previews of Linear issues in documents by connecting a
Linear workspace to {appName}.
</Trans>
</Text>
{integrations.linear.length ? (
<>
<Heading as="h2">
<Flex justify="space-between" auto>
{t("Connected")}
<LinearConnectButton icon={<PlusIcon />} />
</Flex>
</Heading>
<List>
{integrations.linear.map((integration) => {
const linearWorkspace =
integration.settings?.linear?.workspace;
const integrationCreatedBy = integration.user
? integration.user.name
: undefined;
return (
<ListItem
key={linearWorkspace?.id}
small
title={linearWorkspace?.name}
subtitle={
integrationCreatedBy ? (
<>
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
&middot;{" "}
<Time
dateTime={integration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
) : (
<PlaceholderText />
)
}
image={
<TeamLogo
src={linearWorkspace?.logoUrl}
size={AvatarSize.Large}
/>
}
actions={
<ConnectedButton
onClick={integration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?"
)}
/>
}
/>
);
})}
</List>
</>
) : (
<p>
<LinearConnectButton icon={<LinearIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The Linear integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</Scene>
);
}
export default observer(Linear);
@@ -0,0 +1,23 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { LinearUtils } from "../../shared/LinearUtils";
export function LinearConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() =>
redirectTo(LinearUtils.authUrl({ state: { teamId: team.id } }))
}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
+7
View File
@@ -0,0 +1,7 @@
{
"id": "linear",
"name": "Linear",
"priority": 15,
"description": "Adds a Linear integration for link unfurling and converting links to mentions.",
"after": "github"
}
+110
View File
@@ -0,0 +1,110 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration, Team } from "@server/models";
import { APIContext } from "@server/types";
import { Linear } from "../linear";
import * as T from "./schema";
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
const router = new Router();
router.get(
"linear.callback",
auth({
optional: true,
}),
validate(T.LinearCallbackSchema),
transaction(),
async (ctx: APIContext<T.LinearCallbackReq>) => {
const { code, state, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
let parsedState;
try {
parsedState = LinearUtils.parseState(state);
} catch {
ctx.redirect(LinearUtils.errorUrl("invalid_state"));
return;
}
const { teamId } = parsedState;
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the appropriate
// subdomain to complete the oauth flow
if (!user) {
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
transaction,
});
return parseDomain(ctx.host).teamSubdomain === team.subdomain
? ctx.redirect("/")
: ctx.redirectOnClient(
LinearUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
return ctx.redirect(LinearUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(LinearUtils.errorUrl("unauthenticated"));
}
}
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(LinearUtils.errorUrl(error));
return;
}
// validation middleware ensures that code is non-null at this point.
const oauth = await Linear.oauthAccess(code!);
const workspace = await Linear.getInstalledWorkspace(oauth.access_token);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Linear,
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
scopes: oauth.scope.split(" "),
},
{ transaction }
);
await Integration.create<Integration<IntegrationType.Embed>>(
{
service: IntegrationService.Linear,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
linear: {
workspace: {
id: workspace.id,
name: workspace.name,
key: workspace.urlKey,
logoUrl: workspace.logoUrl,
},
},
},
},
{ transaction }
);
ctx.redirect(LinearUtils.successUrl());
}
);
export default router;
+17
View File
@@ -0,0 +1,17 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const LinearCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
}),
});
export type LinearCallbackReq = z.infer<typeof LinearCallbackSchema>;
+25
View File
@@ -0,0 +1,25 @@
import { IsOptional } from "class-validator";
import { Environment } from "@server/env";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators";
class LinearPluginEnvironment extends Environment {
/**
* Linear OAuth2 app client id. To enable integration with Linear.
*/
@Public
@IsOptional()
public LINEAR_CLIENT_ID = this.toOptionalString(environment.LINEAR_CLIENT_ID);
/**
* Linear OAuth2 app client secret. To enable integration with Linear.
*/
@IsOptional()
@CannotUseWithout("LINEAR_CLIENT_ID")
public LINEAR_CLIENT_SECRET = this.toOptionalString(
environment.LINEAR_CLIENT_SECRET
);
}
export default new LinearPluginEnvironment();
+27
View File
@@ -0,0 +1,27 @@
import { Minute } from "@shared/utils/time";
import { Hook, PluginManager } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./api/linear";
import env from "./env";
import { Linear } from "./linear";
import { uninstall } from "./uninstall";
const enabled = !!env.LINEAR_CLIENT_ID && !!env.LINEAR_CLIENT_SECRET;
if (enabled) {
PluginManager.add([
{
...config,
type: Hook.API,
value: router,
},
{
type: Hook.UnfurlProvider,
value: { unfurl: Linear.unfurl, cacheExpiry: Minute.seconds },
},
{
type: Hook.Uninstall,
value: uninstall,
},
]);
}
+204
View File
@@ -0,0 +1,204 @@
import { Issue, LinearClient, WorkflowState } from "@linear/sdk";
import sortBy from "lodash/sortBy";
import { z } from "zod";
import {
IntegrationService,
IntegrationType,
UnfurlResourceType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import User from "@server/models/User";
import { UnfurlIssueAndPR, UnfurlSignature } from "@server/types";
import { LinearUtils } from "../shared/LinearUtils";
import env from "./env";
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number(),
scope: z.string(),
});
export class Linear {
private static supportedUnfurls = [UnfurlResourceType.Issue];
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("code", code);
body.set("client_id", env.LINEAR_CLIENT_ID!);
body.set("client_secret", env.LINEAR_CLIENT_SECRET!);
body.set("redirect_uri", LinearUtils.callbackUrl());
body.set("grant_type", "authorization_code");
const res = await fetch(LinearUtils.tokenUrl, {
method: "POST",
headers,
body,
});
return AccessTokenResponseSchema.parse(await res.json());
}
static async revokeAccess(accessToken: string) {
const headers = {
Authorization: `Bearer ${accessToken}`,
};
await fetch(LinearUtils.revokeUrl, {
method: "POST",
headers,
});
}
static async getInstalledWorkspace(accessToken: string) {
const client = new LinearClient({ accessToken });
return client.organization;
}
/**
*
* @param url Linear resource url
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a Linear issue details
*/
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
const resource = Linear.parseUrl(url);
if (!resource) {
return;
}
const integration = (await Integration.scope("withAuthentication").findOne({
where: {
service: IntegrationService.Linear,
teamId: actor.teamId,
"settings.linear.workspace.key": resource.workspaceKey,
},
})) as Integration<IntegrationType.Embed>;
if (!integration) {
return;
}
try {
const client = new LinearClient({
accessToken: integration.authentication.token,
});
const issue = await client.issue(resource.id);
if (!issue) {
return { error: "Resource not found" };
}
const [author, state, labels] = await Promise.all([
issue.creator,
issue.state,
issue.paginate(issue.labels, {}),
]);
if (!author || !state || !labels) {
return { error: "Failed to fetch auxiliary data from Linear" };
}
const completionPercentage = await Linear.completionPercentage(
client,
issue,
state
);
return {
type: UnfurlResourceType.Issue,
url: issue.url,
id: issue.identifier,
title: issue.title,
description: issue.description ?? null,
author: {
name: author.name,
avatarUrl: author.avatarUrl ?? "",
},
labels: labels.map((label) => ({
name: label.name,
color: label.color,
})),
state: {
type: state.type,
name: state.name,
color: state.color,
completionPercentage,
},
createdAt: issue.createdAt.toISOString(),
transformed_unfurl: true,
} satisfies UnfurlIssueAndPR;
} catch (err) {
Logger.warn("Failed to fetch resource from Linear", err);
return { error: err.message || "Unknown error" };
}
};
private static async completionPercentage(
client: LinearClient,
issue: Issue,
state: WorkflowState
) {
const defaultCompletionPercentage = 0.5; // fallback when we cannot determine actual value.
if (state.type !== "started") {
return defaultCompletionPercentage;
}
const team = await issue.team;
if (!team) {
return defaultCompletionPercentage;
}
const allStates = await client.paginate(client.workflowStates, {
filter: {
team: { id: { eq: team.id } },
type: { eq: "started" },
},
});
const states = sortBy(
allStates.map((s) => ({
name: s.name,
position: s.position,
})),
(s) => s.position
);
const idx = states.findIndex((s) => s.name === state.name);
return idx !== -1
? (idx + 1) / (states.length + 1) // add 1 to states for the "done" state.
: defaultCompletionPercentage;
}
/**
* Parses a given URL and returns resource identifiers for Linear specific URLs
*
* @param url URL to parse
* @returns {object} Containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
*/
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (hostname !== "linear.app") {
return;
}
const parts = pathname.split("/");
const workspaceKey = parts[1];
const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined;
const id = parts[3];
const name = parts[4];
if (!type || !Linear.supportedUnfurls.includes(type)) {
return;
}
return { workspaceKey, type, id, name };
}
}
+25
View File
@@ -0,0 +1,25 @@
import { IntegrationService, IntegrationType } from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import { Linear } from "./linear";
export async function uninstall(
integration: Integration<IntegrationType.Embed>
) {
if (integration.service !== IntegrationService.Linear) {
return;
}
const authentication = await integration.$get("authentication");
if (!authentication) {
return;
}
try {
await Linear.revokeAccess(authentication.token);
} catch (err) {
// suppress error since this is a best-effort operation.
Logger.error("Failed to revoke Linear access token", err);
}
}
+53
View File
@@ -0,0 +1,53 @@
import queryString from "query-string";
import env from "@shared/env";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class LinearUtils {
private static oauthScopes = "read,issues:create";
public static tokenUrl = "https://api.linear.app/oauth/token";
public static revokeUrl = "https://api.linear.app/oauth/revoke";
private static authBaseUrl = "https://linear.app/oauth/authorize";
private static settingsUrl = integrationSettingsPath("linear");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl() {
return this.settingsUrl;
}
static errorUrl(error: string) {
return `${this.settingsUrl}?error=${error}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
params: undefined,
}
) {
return params
? `${baseUrl}/api/linear.callback?${params}`
: `${baseUrl}/api/linear.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.LINEAR_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
scope: this.oauthScopes,
response_type: "code",
prompt: "consent",
actor: "application",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
+12 -1
View File
@@ -13,9 +13,11 @@ import {
RichTextItemResponse,
} from "@notionhq/client/build/src/api-endpoints";
import { RateLimit } from "async-sema";
import emojiRegex from "emoji-regex";
import compact from "lodash/compact";
import { z } from "zod";
import { Second } from "@shared/utils/time";
import { isUrl } from "@shared/utils/urls";
import { NotionUtils } from "../shared/NotionUtils";
import { Block, Page, PageType } from "../shared/types";
import env from "./env";
@@ -37,7 +39,16 @@ const AccessTokenResponseSchema = z.object({
bot_id: z.string(),
workspace_id: z.string(),
workspace_name: z.string().nullish(),
workspace_icon: z.string().url().nullish(),
workspace_icon: z
.string()
.nullish()
.transform((val) => {
const emojiRegexp = emojiRegex();
if (val && (isUrl(val) || emojiRegexp.test(val))) {
return val;
}
return undefined;
}),
});
export class NotionClient {
+29
View File
@@ -277,6 +277,35 @@ describe("#files.get", () => {
);
});
it("should succeed with status 200 ok when avatar is requested using key", async () => {
const user = await buildUser();
const key = path.join("avatars", user.id, uuidV4());
const attachment = await buildAttachment({
key,
teamId: user.teamId,
userId: user.id,
contentType: "image/jpg",
acl: "public-read",
});
await attachment.destroy({
hooks: false,
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
);
copyFileSync(
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
);
const res = await server.get(`/api/files.get?key=${key}`);
expect(res.status).toEqual(200);
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
});
it("should succeed with status 200 ok when avatar is requested using key", async () => {
const user = await buildUser();
const key = path.join("avatars", user.id, uuidV4());
+5 -9
View File
@@ -78,17 +78,13 @@ router.get(
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
const skipAuthorize = isPublicBucket || isSignedRequest;
const cacheHeader = "max-age=604800, immutable";
const attachment = await Attachment.findOne({
where: { key },
});
// Attachment is requested with a key, but it was not found
if (!attachment && !!ctx.input.query.key) {
throw NotFoundError();
}
const attachment = await Attachment.findByKey(key);
if (!skipAuthorize) {
if (!attachment && !!ctx.input.query.key) {
throw NotFoundError();
}
authorize(actor, "read", attachment);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -264,7 +264,6 @@ describe("accountProvisioner", () => {
avatarUrl: faker.internet.avatar(),
},
team: {
name: existingTeam.name,
avatarUrl: existingTeam.avatarUrl,
subdomain: faker.internet.domainWord(),
},
@@ -308,7 +307,6 @@ describe("accountProvisioner", () => {
avatarUrl: faker.internet.avatar(),
},
team: {
name: team.name,
avatarUrl: team.avatarUrl,
subdomain: faker.internet.domainWord(),
},
+2 -1
View File
@@ -43,7 +43,7 @@ type Props = {
*/
teamId?: string;
/** The displayed name of the team */
name: string;
name?: string;
/** The domain name from the email of the user logging in */
domain?: string;
/** The preferred subdomain to provision for the team if not yet created */
@@ -92,6 +92,7 @@ async function accountProvisioner({
try {
result = await teamProvisioner({
name: "Wiki",
...teamParams,
authenticationProvider: authenticationProviderParams,
ip,
+2 -9
View File
@@ -3,7 +3,6 @@ import slugify from "slugify";
import { RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import { traceFunction } from "@server/logging/tracing";
import { Team, Event } from "@server/models";
import { generateAvatarUrl } from "@server/utils/avatars";
type Props = {
/** The displayed name of the team */
@@ -29,20 +28,14 @@ type Props = {
async function teamCreator({
name,
domain,
subdomain,
avatarUrl,
authenticationProviders,
ip,
transaction,
}: Props): Promise<Team> {
// If the service did not provide a logo/avatar then we attempt to generate
// one via ClearBit, or fallback to colored initials in worst case scenario
if (!avatarUrl || !avatarUrl.startsWith("http")) {
avatarUrl = await generateAvatarUrl({
domain,
id: subdomain,
});
if (!avatarUrl?.startsWith("http")) {
avatarUrl = null;
}
const team = await Team.create(
+1 -1
View File
@@ -192,7 +192,7 @@ describe("userProvisioner", () => {
it("should prefer isAdmin argument over defaultUserRole", async () => {
const team = await buildTeam({
defaultUserRole: "viewer",
defaultUserRole: UserRole.Viewer,
});
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
@@ -86,7 +86,8 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
}
return;
},
30
30,
10000
);
}
@@ -0,0 +1,15 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
queryInterface.addColumn("integrations", "issueSources", {
type: Sequelize.JSONB,
allowNull: true,
});
},
async down(queryInterface, Sequelize) {
queryInterface.removeColumn("integrations", "issueSources");
},
};
+15
View File
@@ -5,6 +5,7 @@ import {
InferAttributes,
InferCreationAttributes,
QueryTypes,
FindOptions,
} from "sequelize";
import {
BeforeDestroy,
@@ -164,6 +165,20 @@ class Attachment extends IdModel<
// static methods
/**
* Find an attachment by its key.
*
* @param key - The key of the attachment to find.
* @param options - Additional options for the query.
* @returns A promise resolving to the attachment with the given key, or null if not found.
*/
static async findByKey(
key: string,
options?: FindOptions<Attachment>
): Promise<Attachment | null> {
return this.findOne({ where: { key }, ...options });
}
/**
* Get the total size of all attachments for a given team.
*
+29
View File
@@ -11,6 +11,7 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -78,6 +79,34 @@ describe("#delete", () => {
expect(newDocument?.deletedAt).toBeTruthy();
});
test("should soft delete archived document in an archived collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
archivedAt: new Date(),
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
archivedAt: new Date(),
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
await collection.addDocumentToStructure(document, 0);
await document.delete(user);
const [newDocument, newCollection] = await Promise.all([
Document.findByPk(document.id, {
paranoid: false,
}),
Collection.findByPk(collection.id),
]);
expect(newDocument?.lastModifiedById).toEqual(user.id);
expect(newDocument?.deletedAt).toBeTruthy();
expect(newCollection?.documentStructure).toEqual([]);
});
it("should delete draft without collection", async () => {
const user = await buildUser();
const document = await buildDraftDocument();
+13 -5
View File
@@ -1112,18 +1112,26 @@ class Document extends ArchivableModel<
// Delete a document, archived or otherwise.
delete = (user: User) =>
this.sequelize.transaction(async (transaction: Transaction) => {
if (!this.archivedAt && !this.template && this.collectionId) {
// delete any children and remove from the document structure
const collection = await Collection.findByPk(this.collectionId, {
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
});
await collection?.deleteDocument(this, { transaction });
} else {
if (!this.archivedAt || (this.archivedAt && collection?.archivedAt)) {
await collection?.deleteDocument(this, { transaction });
deleted = true;
}
}
if (!deleted) {
await this.destroy({
transaction,
});
deleted = true;
}
this.lastModifiedById = user.id;
+4
View File
@@ -13,6 +13,7 @@ import {
IsIn,
AfterDestroy,
} from "sequelize-typescript";
import { IssueSource } from "@shared/schema";
import { IntegrationType, IntegrationService } from "@shared/types";
import type { IntegrationSettings } from "@shared/types";
import Collection from "@server/models/Collection";
@@ -53,6 +54,9 @@ class Integration<T = unknown> extends ParanoidModel<
@Column(DataType.ARRAY(DataType.STRING))
events: string[];
@Column(DataType.JSONB)
issueSources: IssueSource[] | null;
// associations
@BelongsTo(() => User, "userId")
@@ -251,6 +251,10 @@ describe("NotificationHelper", () => {
userId: subscribedUser.id,
collectionId: document.collectionId!,
});
await buildSubscription({
userId: subscribedUser.id,
documentId: document.id,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
+9 -7
View File
@@ -1,4 +1,5 @@
import uniq from "lodash/uniq";
import uniqBy from "lodash/uniqBy";
import { Op } from "sequelize";
import {
NotificationEventType,
@@ -187,7 +188,6 @@ export default class NotificationHelper {
});
} else {
const subscriptions = await Subscription.findAll({
attributes: ["userId"],
where: {
userId: {
[Op.ne]: actorId,
@@ -206,17 +206,19 @@ export default class NotificationHelper {
],
});
recipients = subscriptions.map((s) => s.user);
recipients = uniqBy(
subscriptions.map((s) => s.user),
(user) => user.id
);
}
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(notificationType)
);
const filtered = [];
for (const recipient of recipients) {
if (recipient.isSuspended) {
if (
recipient.isSuspended ||
!recipient.subscribedToEventType(notificationType)
) {
continue;
}
+7 -46
View File
@@ -1,19 +1,18 @@
import { JSDOM } from "jsdom";
import compact from "lodash/compact";
import flatten from "lodash/flatten";
import isEqual from "lodash/isEqual";
import isMatch from "lodash/isMatch";
import uniq from "lodash/uniq";
import { Node, DOMSerializer, Fragment, Mark } from "prosemirror-model";
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
import * as React from "react";
import { renderToString } from "react-dom/server";
import styled, { ServerStyleSheet, ThemeProvider } from "styled-components";
import { prosemirrorToYDoc } from "y-prosemirror";
import * as Y from "yjs";
import EditorContainer from "@shared/editor/components/Styles";
import embeds from "@shared/editor/embeds";
import GlobalStyles from "@shared/styles/globals";
import light from "@shared/styles/theme";
import { MentionType, ProsemirrorData } from "@shared/types";
import { MentionType, ProsemirrorData, UnfurlResponse } from "@shared/types";
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isRTL } from "@shared/utils/rtl";
@@ -43,6 +42,8 @@ export type MentionAttrs = {
modelId: string;
actorId: string | undefined;
id: string;
href?: string;
unfurl?: UnfurlResponse[keyof UnfurlResponse];
};
@trace()
@@ -61,47 +62,7 @@ export class ProsemirrorHelper {
);
}
let node = parser.parse(input);
// in the editor embeds are created at runtime by converting links into
// embeds where they match.Because we're converting to a CRDT structure on
// the server we need to mimic this behavior.
function urlsToEmbeds(node: Node): Node {
if (node.type.name === "paragraph") {
for (const textNode of node.content.content) {
for (const embed of embeds) {
if (
textNode.text &&
textNode.marks.some(
(m: Mark) =>
m.type.name === "link" && m.attrs.href === textNode.text
) &&
embed.matcher(textNode.text)
) {
return schema.nodes.embed.createAndFill({
href: textNode.text,
}) as Node;
}
}
}
}
if (node.content) {
const contentAsArray =
node.content instanceof Fragment
? node.content.content
: node.content;
// @ts-expect-error content
node.content = Fragment.fromArray(contentAsArray.map(urlsToEmbeds));
}
return node;
}
if (node) {
node = urlsToEmbeds(node);
}
const node = parser.parse(input);
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
}
@@ -235,7 +196,7 @@ export class ProsemirrorHelper {
node.descendants((childNode: Node) => {
if (
childNode.type.name === "mention" &&
isEqual(childNode.attrs, mention)
isMatch(childNode.attrs, mention)
) {
foundMention = true;
return false;
+2 -11
View File
@@ -2,7 +2,7 @@ import invariant from "invariant";
import filter from "lodash/filter";
import { CollectionPermission } from "@shared/types";
import { Collection, User, Team } from "@server/models";
import { allow, can } from "./cancan";
import { allow } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createCollection", Team, (actor, team) =>
@@ -67,15 +67,6 @@ allow(
}
);
allow(User, "export", Collection, (actor, collection) =>
and(
//
can(actor, "read", collection),
!actor.isViewer,
!actor.isGuest
)
);
allow(User, "share", Collection, (user, collection) => {
if (
!collection ||
@@ -161,7 +152,7 @@ allow(
}
);
allow(User, ["update", "archive"], Collection, (user, collection) =>
allow(User, ["update", "export", "archive"], Collection, (user, collection) =>
and(
!!collection,
!!collection?.isActive,
+54 -38
View File
@@ -71,47 +71,63 @@ const presentDocument = (
const presentPR = (
data: Record<string, any>
): UnfurlResponse[UnfurlResourceType.PR] => ({
url: data.html_url,
type: UnfurlResourceType.PR,
id: `#${data.number}`,
title: data.title,
description: data.body,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
state: {
name: data.merged ? "merged" : data.state,
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
},
createdAt: data.created_at,
});
): UnfurlResponse[UnfurlResourceType.PR] => {
// TODO: For backwards compatibility, remove once cache has expired in next release.
if (data.transformed_unfurl) {
delete data.transformed_unfurl;
return data as UnfurlResponse[UnfurlResourceType.PR]; // this would have been transformed by the unfurl plugin.
}
return {
url: data.html_url,
type: UnfurlResourceType.PR,
id: `#${data.number}`,
title: data.title,
description: data.body,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
state: {
name: data.merged ? "merged" : data.state,
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
},
createdAt: data.created_at,
};
};
const presentIssue = (
data: Record<string, any>
): UnfurlResponse[UnfurlResourceType.Issue] => ({
url: data.html_url,
type: UnfurlResourceType.Issue,
id: `#${data.number}`,
title: data.title,
description: data.body_text,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
labels: data.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: data.state,
color: GitHubUtils.getColorForStatus(
data.state === "closed" ? "done" : data.state
),
},
createdAt: data.created_at,
});
): UnfurlResponse[UnfurlResourceType.Issue] => {
// TODO: For backwards compatibility, remove once cache has expired in next release.
if (data.transformed_unfurl) {
delete data.transformed_unfurl;
return data as UnfurlResponse[UnfurlResourceType.Issue]; // this would have been transformed by the unfurl plugin.
}
return {
url: data.html_url,
type: UnfurlResourceType.Issue,
id: `#${data.number}`,
title: data.title,
description: data.body_text,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
labels: data.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: data.state,
color: GitHubUtils.getColorForStatus(
data.state === "closed" ? "done" : data.state
),
},
createdAt: data.created_at,
};
};
const presentLastOnlineInfoFor = (user: User) => {
const locale = dateLocale(user.language);
@@ -3,6 +3,7 @@ import { Integration } from "@server/models";
import BaseProcessor from "@server/queues/processors/BaseProcessor";
import { IntegrationEvent, Event } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import CacheIssueSourcesTask from "../tasks/CacheIssueSourcesTask";
export default class IntegrationCreatedProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["integrations.create"];
@@ -18,6 +19,11 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
return;
}
// Store the available issue sources in the integration record.
await CacheIssueSourcesTask.schedule({
integrationId: integration.id,
});
// Clear the cache of unfurled data for the team as it may be stale now.
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
}
@@ -0,0 +1,32 @@
import { Integration } from "@server/models";
import { sequelize } from "@server/storage/database";
import { Hook, PluginManager } from "@server/utils/PluginManager";
import BaseTask from "./BaseTask";
const plugins = PluginManager.getHooks(Hook.IssueProvider);
type Props = {
integrationId: string;
};
export default class CacheIssueSourcesTask extends BaseTask<Props> {
async perform({ integrationId }: Props) {
const integration = await Integration.findByPk(integrationId);
if (!integration) {
return;
}
const plugin = plugins.find((p) => p.value.service === integration.service);
if (!plugin) {
return;
}
const sources = await plugin.value.fetchSources(integration);
await sequelize.transaction(async (transaction) => {
await integration.reload({ transaction, lock: transaction.LOCK.UPDATE });
integration.issueSources = sources;
await integration.save({ transaction });
});
}
}
@@ -85,16 +85,21 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
)
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
for (const recipient of recipients) {
await Notification.create({
event: NotificationEventType.CreateComment,
userId: recipient.id,
actorId: comment.createdById,
teamId: document.teamId,
commentId: comment.id,
documentId: document.id,
});
}
await sequelize.transaction(async (transaction) => {
for (const recipient of recipients) {
await Notification.create(
{
event: NotificationEventType.CreateComment,
userId: recipient.id,
actorId: comment.createdById,
teamId: document.teamId,
commentId: comment.id,
documentId: document.id,
},
{ transaction }
);
}
});
}
public get options() {
+1 -1
View File
@@ -20,7 +20,7 @@ jest.mock("dns", () => ({
jest
.spyOn(Iframely, "requestResource")
.mockImplementation(() => Promise.resolve(undefined));
.mockImplementation(() => Promise.resolve({}));
const server = getTestServer();
+4 -3
View File
@@ -98,11 +98,12 @@ router.post(
}
for (const plugin of plugins) {
const data = await plugin.value.unfurl(url, actor);
if (data) {
if ("error" in data) {
const unfurl = await plugin.value.unfurl(url, actor);
if (unfurl) {
if ("error" in unfurl) {
return (ctx.response.status = 204);
} else {
const data = unfurl as Unfurl;
await CacheHelper.setData(
CacheHelper.getUnfurlKey(actor.teamId, url),
data,
+18 -2
View File
@@ -10,6 +10,7 @@ import {
JSONValue,
UnfurlResourceType,
ProsemirrorData,
UnfurlResponse,
} from "@shared/types";
import { BaseSchema } from "@server/routes/api/schema";
import { AccountProvisionerResult } from "./commands/accountProvisioner";
@@ -576,12 +577,27 @@ export type CollectionJSONExport = {
};
};
export type Unfurl = { [x: string]: JSONValue; type: UnfurlResourceType };
export type UnfurlIssueAndPR = (
| UnfurlResponse[UnfurlResourceType.Issue]
| UnfurlResponse[UnfurlResourceType.PR]
) & { transformed_unfurl: true };
export type Unfurl =
| UnfurlIssueAndPR
| {
type: Exclude<
UnfurlResourceType,
UnfurlResourceType.Issue | UnfurlResourceType.PR
>;
[x: string]: JSONValue;
};
export type UnfurlError = { error: string };
export type UnfurlSignature = (
url: string,
actor?: User
) => Promise<Unfurl | void>;
) => Promise<Unfurl | UnfurlError | undefined>;
export type UninstallSignature = (integration: Integration) => Promise<void>;
+15
View File
@@ -0,0 +1,15 @@
import { IssueSource } from "@shared/schema";
import { IntegrationType, IssueTrackerIntegrationService } from "@shared/types";
import { Integration } from "@server/models";
export abstract class BaseIssueProvider {
service: IssueTrackerIntegrationService;
constructor(service: IssueTrackerIntegrationService) {
this.service = service;
}
abstract fetchSources(
integration: Integration<IntegrationType.Embed>
): Promise<IssueSource[]>;
}
+5 -5
View File
@@ -18,11 +18,14 @@ export class CacheHelper {
* @param key Cache key
* @param callback Callback to get the data if not found in cache
* @param expiry Cache data expiry in seconds
* @param lockTimeout Lock timeout in milliseconds
* @returns The data from cache or the result of the callback
*/
public static async getDataOrSet<T>(
key: string,
callback: () => Promise<T | undefined>,
expiry?: number
expiry: number,
lockTimeout: number = MutexLock.defaultLockTimeout
): Promise<T | undefined> {
let cache = await this.getData<T>(key);
@@ -35,10 +38,7 @@ export class CacheHelper {
const lockKey = `lock:${key}`;
try {
try {
lock = await MutexLock.lock.acquire(
[lockKey],
MutexLock.defaultLockTimeout
);
lock = await MutexLock.lock.acquire([lockKey], lockTimeout);
} catch (err) {
Logger.error(`Could not acquire lock for ${key}`, err);
}
+3
View File
@@ -9,6 +9,7 @@ import Logger from "@server/logging/Logger";
import type BaseProcessor from "@server/queues/processors/BaseProcessor";
import type BaseTask from "@server/queues/tasks/BaseTask";
import { UnfurlSignature, UninstallSignature } from "@server/types";
import { BaseIssueProvider } from "./BaseIssueProvider";
export enum PluginPriority {
VeryHigh = 0,
@@ -25,6 +26,7 @@ export enum Hook {
API = "api",
AuthProvider = "authProvider",
EmailTemplate = "emailTemplate",
IssueProvider = "issueProvider",
Processor = "processor",
Task = "task",
UnfurlProvider = "unfurl",
@@ -39,6 +41,7 @@ type PluginValueMap = {
[Hook.API]: Router;
[Hook.AuthProvider]: { router: Router; id: string };
[Hook.EmailTemplate]: typeof BaseEmail;
[Hook.IssueProvider]: BaseIssueProvider;
[Hook.Processor]: typeof BaseProcessor;
[Hook.Task]: typeof BaseTask<any>;
[Hook.Uninstall]: UninstallSignature;
-9
View File
@@ -1,9 +0,0 @@
import { generateAvatarUrl } from "./avatars";
it("should return clearbit url if available", async () => {
const url = await generateAvatarUrl({
id: "google",
domain: "google.com",
});
expect(url).toBe("https://logo.clearbit.com/google.com");
});
-28
View File
@@ -1,28 +0,0 @@
import crypto from "crypto";
import fetch from "./fetch";
export async function generateAvatarUrl({
id,
domain,
}: {
id: string;
domain?: string;
}) {
// attempt to get logo from Clearbit API. If one doesn't exist then
// fall back to using tiley to generate a placeholder logo
const hash = crypto.createHash("sha256");
hash.update(id);
let cbResponse, cbUrl;
if (domain) {
cbUrl = `https://logo.clearbit.com/${domain}`;
try {
cbResponse = await fetch(cbUrl);
} catch (err) {
// okay
}
}
return cbUrl && cbResponse && cbResponse.status === 200 ? cbUrl : null;
}
+87
View File
@@ -0,0 +1,87 @@
import {
buildCollection,
buildDocument,
buildStar,
buildTeam,
buildUser,
} from "@server/test/factories";
import { collectionIndexing, starIndexing } from "./indexing";
describe("collectionIndexing", () => {
it("should generate index for collections without index", async () => {
const team = await buildTeam();
const collections = await Promise.all([
buildCollection({
teamId: team.id,
}),
buildCollection({
teamId: team.id,
}),
]);
// Set index to null to simulate no index
collections[0].index = null;
collections[1].index = null;
await collections[0].save({ hooks: false });
await collections[1].save({ hooks: false });
const result = await collectionIndexing(team.id, {});
expect(Object.keys(result).length).toBe(2);
expect(result[collections[0].id]).toBeTruthy();
expect(result[collections[1].id]).toBeTruthy();
});
it("should maintain existing indices", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
index: "a1",
});
const result = await collectionIndexing(team.id, {});
expect(result[collection.id]).toBe("a1");
});
});
describe("starIndexing", () => {
it("should generate index for stars without index", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument();
const stars = await Promise.all([
buildStar({
userId: user.id,
documentId: document.id,
}),
buildStar({
userId: user.id,
documentId: document.id,
}),
]);
// Set index to null to simulate no index
stars[0].index = null;
stars[1].index = null;
await stars[0].save({ hooks: false });
await stars[1].save({ hooks: false });
const result = await starIndexing(user.id);
expect(Object.keys(result).length).toBe(2);
expect(result[stars[0].id]).toBeTruthy();
expect(result[stars[1].id]).toBeTruthy();
});
it("should maintain existing indices", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument();
const star = await buildStar({
userId: user.id,
documentId: document.id,
index: "a1",
});
const result = await starIndexing(user.id);
expect(result[star.id]).toBe("a1");
});
});
+5 -5
View File
@@ -10,10 +10,8 @@ export async function collectionIndexing(
const collections = await Collection.findAll({
where: {
teamId,
// no point in maintaining index of deleted collections.
deletedAt: null,
},
attributes: ["id", "index", "name"],
attributes: ["id", "index", "name", "teamId"],
transaction,
});
@@ -26,7 +24,9 @@ export async function collectionIndexing(
for (const collection of sortable) {
if (collection.index === null) {
collection.index = fractionalIndex(previousIndex, null);
promises.push(collection.save({ fields: ["index"], transaction })); // save only index to prevent overwriting other unfetched fields.
promises.push(
collection.save({ fields: ["index"], silent: true, transaction })
);
}
previousIndex = collection.index;
@@ -67,7 +67,7 @@ export async function starIndexing(userId: string) {
for (const star of sortable) {
if (star.index === null) {
star.index = fractionalIndex(previousIndex, null);
promises.push(star.save());
promises.push(star.save({ silent: true }));
}
previousIndex = star.index;
@@ -1,43 +1,18 @@
import * as React from "react";
import styled from "styled-components";
import React from "react";
import { BaseIconProps } from ".";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
export function GitHubIssueStatusIcon(props: BaseIconProps) {
const { state, className } = props;
/**
* Issue status icon based on GitHub issue status, but can be used for any git-style integration.
*/
export function IssueStatusIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
switch (state.name) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
fill={state.color}
className={className}
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
@@ -49,8 +24,8 @@ function BaseIcon(props: Props) {
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
fill={state.color}
className={className}
>
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
@@ -62,8 +37,8 @@ function BaseIcon(props: Props) {
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
fill={state.color}
className={className}
>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />
</svg>
@@ -0,0 +1,75 @@
import * as React from "react";
import { useTheme } from "styled-components";
import { isSafari } from "../../utils/browser";
import { BaseIconProps } from ".";
enum StateType {
Triage = "triage",
Backlog = "backlog",
Unstarted = "unstarted",
Started = "started",
Completed = "completed",
Canceled = "canceled",
}
export function LinearIssueStatusIcon(props: BaseIconProps) {
const theme = useTheme();
const { state } = props;
const percentage =
state.type === StateType.Triage ||
state.type === StateType.Backlog ||
state.type === StateType.Unstarted
? 0
: state.type === StateType.Started
? state.completionPercentage ?? 0.5
: 1;
const isTriage = state.type === StateType.Triage;
const isBacklog = state.type === StateType.Backlog;
const isCompleted = state.type === StateType.Completed;
// Due to some rendering issues and differences between browsers, the logical constant 4 in the rendering below
// needs to be a bit less to make 50% look like half a circle.
const magicFour = isSafari() ? 3.895 : 3.98;
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle
cx={7}
cy={7}
r={isTriage ? 3.5 : 6}
fill="none"
stroke={state.color}
strokeWidth={isTriage ? 7 : 1.5}
strokeDasharray={isTriage ? "2 0" : isBacklog ? "1.4 1.74" : "3.14 0"}
strokeDashoffset={isTriage ? 3.2 : isBacklog ? 0.65 : -0.7}
/>
<circle
cx={7}
cy={7}
r={percentage === 1 ? 3 : 2}
fill="none"
stroke={state.color}
strokeWidth={percentage === 1 ? 6 : 4}
strokeDasharray={`${
percentage * Math.PI * (percentage === 1 ? 6 : magicFour)
} 100`}
strokeDashoffset={0}
transform={`rotate(-90 7 7)`}
/>
{(isTriage || percentage === 1) && (
<path
className="icon"
stroke="none"
d={isTriage ? triageIcon : isCompleted ? checkMarkIcon : closeIcon}
fill={theme.background}
/>
)}
</svg>
);
}
const checkMarkIcon =
"M10.951 4.24896C11.283 4.58091 11.283 5.11909 10.951 5.45104L5.95104 10.451C5.61909 10.783 5.0809 10.783 4.74896 10.451L2.74896 8.45104C2.41701 8.11909 2.41701 7.5809 2.74896 7.24896C3.0809 6.91701 3.61909 6.91701 3.95104 7.24896L5.35 8.64792L9.74896 4.24896C10.0809 3.91701 10.6191 3.91701 10.951 4.24896Z";
const triageIcon =
"M8.0126 7.98223V9.50781C8.0126 9.92901 8.52329 10.1548 8.85102 9.87854L11.8258 7.37066C12.0581 7.17486 12.0581 6.82507 11.8258 6.62927L8.85102 4.12139C8.52329 3.84509 8.0126 4.07092 8.0126 4.49212V6.01763H5.98739V4.49218C5.98739 4.07098 5.4767 3.84515 5.14897 4.12146L2.17419 6.62933C1.94194 6.82513 1.94194 7.17492 2.17419 7.37072L5.14897 9.8786C5.4767 10.1549 5.98739 9.92907 5.98739 9.50787V7.98223H8.0126Z";
const closeIcon =
"M3.73657 3.73657C4.05199 3.42114 4.56339 3.42114 4.87881 3.73657L5.93941 4.79716L7 5.85775L9.12117 3.73657C9.4366 3.42114 9.94801 3.42114 10.2634 3.73657C10.5789 4.05199 10.5789 4.56339 10.2634 4.87881L8.14225 7L10.2634 9.12118C10.5789 9.4366 10.5789 9.94801 10.2634 10.2634C9.94801 10.5789 9.4366 10.5789 9.12117 10.2634L7 8.14225L4.87881 10.2634C4.56339 10.5789 4.05199 10.5789 3.73657 10.2634C3.42114 9.94801 3.42114 9.4366 3.73657 9.12118L4.79716 8.06059L5.85775 7L3.73657 4.87881C3.42114 4.56339 3.42114 4.05199 3.73657 3.73657Z";
@@ -0,0 +1,42 @@
import * as React from "react";
import styled from "styled-components";
import {
IntegrationService,
IssueTrackerIntegrationService,
UnfurlResourceType,
UnfurlResponse,
} from "../../types";
import { GitHubIssueStatusIcon } from "./GitHubIssueStatusIcon";
import { LinearIssueStatusIcon } from "./LinearIssueStatusIcon";
export type BaseIconProps = {
state: UnfurlResponse[UnfurlResourceType.Issue]["state"];
className?: string;
};
type Props = BaseIconProps & {
size?: number;
service: IssueTrackerIntegrationService;
};
export function IssueStatusIcon(props: Props) {
return <Icon size={props.size}>{getIcon(props)}</Icon>;
}
function getIcon({ service, ...rest }: Props) {
switch (service) {
case IntegrationService.GitHub:
return <GitHubIssueStatusIcon {...rest} />;
case IntegrationService.Linear:
return <LinearIssueStatusIcon {...rest} />;
}
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;

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