Compare commits

...

86 Commits

Author SHA1 Message Date
Salihu 2c0f375901 requested changes 2026-03-21 16:07:08 +01:00
Salihu 4894e92d6b add gradient 2026-03-21 16:07:08 +01:00
Salihu 5e6eebd0ec collapsible code blocks 2026-03-21 16:07:03 +01:00
Tom Moor a4badbea9c feat: Role preference for collection template mangement (#11821)
* wip

* ui

* test

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-20 23:57:38 -04:00
Tom Moor f22bc4a0b2 Update README.md 2026-03-20 23:35:32 -04:00
Tom Moor 5693618de4 Add translation hooks to transactional emails (#11785)
* First pass

* fix: Missing translations

* fix: Missing translations

* welcome

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* translations

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-20 23:28:51 -04:00
Tom Moor a0039b2a09 Add keyboard access to mermaid diagram editing (#11834) 2026-03-20 23:25:43 -04:00
Tom Moor fa17f78ae6 fix: Disable embed option for internal link pastes (#11837)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 23:25:26 -04:00
Tom Moor beec9f5675 fix: Empty screen after login with post-redirect (#11833)
closes #11811
2026-03-20 12:09:18 -04:00
Tom Moor 5256cdc185 fix: Guard editDiagram usage (#11830)
closes #11827
2026-03-20 11:18:19 -04:00
Tom Moor 1bd6ad830e MCP improvements (#11822)
* fix: Data always included in list_documents response

* Remove resources, add fetch tool
Fix pagination arguments do not accept string

* type -> resource

* Add URL resolving
2026-03-20 09:45:50 -04:00
Tom Moor 9efcb2d534 fix: GitLab work_items paste detection (#11820)
closes #11819
2026-03-19 17:56:33 -04:00
Copilot 14fc3b01db Fix suggestion menus blocked by placeholder marks (#11796)
* Initial plan

* fix: suggestion menus not opening when typing trigger inside placeholder mark

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-19 17:45:03 -04:00
Tom Moor 05eac5bc3b v1.6.1 2026-03-18 19:49:24 -04:00
Igor Loskutov 64dc5e8ea7 fix: guard against concurrent restore in documentPermanentDeleter (#11775)
* fix: guard against concurrent restore in documentPermanentDeleter

* fix: bake deletedAt check into documentPermanentDeleter destroy WHERE clause
2026-03-18 08:33:09 -04:00
Tom Moor f03ac1f8de Add "Create a nested doc" to @mention (#11800)
* Mention menu nested doc

* refactor
2026-03-18 08:32:56 -04:00
Apoorv Mishra 07099bb4f6 fix: restore image upload (#11803) 2026-03-18 08:32:34 -04:00
Tom Moor 4673ff0840 fix: Clicking on templates in settings table does nothing 2026-03-17 23:47:00 -04:00
Copilot 500c3f91b0 Support GitLab work_items URL structure in unfurl integration (#11795)
* Initial plan

* Support GitLab work_items URL structure in parseUrl

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-17 22:40:47 -04:00
Tom Moor f8098ab464 feat: Adds API key authentication for MCP server (#11798)
* feat: Adds API key authentication for MCP server

* Add AuthenticationHelper test
2026-03-17 22:40:35 -04:00
Tom Moor 3740e09e5c feat: Expose moving documents within a collection (#11799) 2026-03-17 22:40:25 -04:00
Tom Moor 62cfd4e9bc chore: Cleanup working tables left in db if midrun abort (#11786) 2026-03-17 08:05:32 -04:00
Copilot 85072dab92 fix: Preserve port in OAuth metadata URLs when self-hosted behind a reverse proxy (#11791)
* Initial plan

* fix: Preserve port in OAuth metadata URLs when behind reverse proxy

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-17 08:04:40 -04:00
Copilot 1e8d9b5f80 fix: Support mailbox format for SMTP_FROM_EMAIL and SMTP_REPLY_EMAIL (#11784)
* Initial plan

* fix: Handle SMTP_FROM_EMAIL/SMTP_REPLY_EMAIL in mailbox format

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-16 22:48:56 -04:00
Tom Moor 613877714b fix: Page hang with corrupted PNG upload (#11783) 2026-03-16 21:43:38 -04:00
wmTJc9IK0Q cc1c4b22d4 Apply full width to print layout (#11768)
* Apply full width to print layout

* Fix closing parens
2026-03-16 08:51:03 -04:00
Tom Moor a9401c9bb6 fix: Race condition when editing title while doc is saving (#11764) 2026-03-15 15:47:41 -04:00
github-actions[bot] 1345471338 chore: Compressed inefficient images automatically (#11763)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2026-03-15 15:46:14 -04:00
Tom Moor 0ddddac9c9 Add maskable and monochrome icon variants (#11762)
* Add maskable and monochrome icon variants

* Optimised images with calibre/image-actions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-15 15:43:44 -04:00
Tom Moor 24954204ea v1.6.0 2026-03-15 12:18:08 -04:00
Tom Moor 1a893b0e45 Group sync framework (#11684)
Adds group sync from external authentication providers, allowing team group memberships to be automatically managed based on provider data on sign-in in the future.
2026-03-14 23:02:20 -04:00
Translate-O-Tron 255efe9844 New Crowdin updates (#11688)
* fix: New Romanian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Italian translations from Crowdin [ci skip]
2026-03-14 21:35:57 -04:00
Tom Moor 20e55141de Move toggle container up in block menu 2026-03-14 21:17:17 -04:00
Tom Moor 9940f48efa Add flags to Team model to match User (#11758) 2026-03-14 20:17:03 -04:00
Liam Stanley b1a192c078 fix: don't force prompt for Discord OAuth2 (#11757)
Signed-off-by: Liam Stanley <liam@liam.sh>
2026-03-14 19:20:13 -04:00
Copilot 22138957ab Add Project unfurl support to GitLab plugin (#11752)
* Initial plan

* Add GitLab Project unfurl support

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Fix TypeScript errors: add explicit return type to parseUrl

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* tweaks

* progress

* Remove log noise

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-03-14 19:14:35 -04:00
Tom Moor ff0a1766f8 fix: Add exact ID/slug lookup for list_documents and list_collections (#11756) 2026-03-14 17:42:14 -04:00
Copilot d1203408b5 Add GitHub Project V2 unfurl support (#11753)
* Initial plan

* Add GitHub Project V2 unfurl support to the GitHub plugin

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Various fixes

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-03-14 17:13:35 -04:00
Tom Moor 576117e27b Update AGENTS.md 2026-03-14 16:19:51 -04:00
Tom Moor 4bc0f15323 Move group management to sub-page (#11755)
* chore: Move group management to sub-page

* refactor
2026-03-14 16:00:33 -04:00
Copilot 36d555f3fb Add Linear project unfurling support (#11525)
* Initial plan

* Add Project type and unfurl implementation for Linear projects

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Fix linter issues - remove unused import and rename unused parameter

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Make actor parameter optional in unfurl helper methods

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* fix: Resolve type errors in Linear project unfurl

Use project.status (ProjectStatus object) instead of the deprecated
project.state (string) field, add satisfies constraint, and fix
exhaustive return in unfurl switch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Determine mention type

* styling

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 11:03:04 -04:00
Tom Moor 350f69e194 fix: Stale collaborator IDs (#11749) 2026-03-14 09:38:38 -04:00
Copilot a92a1785ff Enable CMD+Shift+L theme toggle on publicly shared pages (#11750)
* Initial plan

* Add CMD+Shift+L keyboard shortcut to toggle theme on shared pages

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-14 09:38:27 -04:00
Copilot 631a4b0efa Default PDF attachments to non-embedded on upload (#11745)
* Initial plan

* Default PDF preview to false when uploading via drag and drop

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Preserve PDF preview for block menu option and attachment replacement

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-13 22:22:11 -04:00
Copilot 52077e4d47 Add PDF preview toggle button to attachment formatting toolbar (#11746)
* Initial plan

* Add PDF preview toggle button to attachment formatting toolbar

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Tweak icon

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-03-13 22:07:48 -04:00
Copilot 79fc0b90b9 Only include passkeys in auth.config providers when team has registered passkeys (#11748)
* Initial plan

* Only return passkeys auth provider if team has at least one registered passkey

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-13 21:46:56 -04:00
Tom Moor ea4fbdb7bb fix: Filter relationships returned from list endpoint (#11738)
* fix: Filter relationships returned from list endpoint

* fix: BacklinksProcessor does not check teamId

* Port from upstream
2026-03-12 22:09:31 -04:00
Tom Moor 88f7ef9d03 fix: Hide fullscreen control in present mode on mobile iOS (#11737) 2026-03-12 20:50:25 -04:00
Copilot 951fb8a34a Add "Open in Desktop" option to document menu (#11729)
* Initial plan

* Add Open in Desktop option to document menu

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Tweak naming

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-03-12 09:33:11 -04:00
Daniel Lo Nigro 0b5bd31017 Update comment about auth providers in .env.sample (#11731) 2026-03-12 08:35:07 -04:00
Tom Moor 48c7bd990a fix: Incorrect policy on file operations (#11728) 2026-03-11 23:55:45 -04:00
Tom Moor 54f2994b13 fix: DocumentExplorer jump on mouse hover (#11727)
* fix: DocumentExplorer jump on nav

* refactor
2026-03-11 20:25:33 -04:00
Tom Moor 8d9cd25b4e perf: Query pagination (#11726)
* Add client version header

* Include commit sha in x-client-version header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* perf: Removes count query for client requests

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:25:23 -04:00
Tom Moor 16a4b8417e fix: MobX observable warning in dialog store 2026-03-11 20:00:45 -04:00
Tom Moor c993305c1b fix: MobX warning in SearchActions 2026-03-11 19:30:02 -04:00
Tom Moor 70891d5fa7 fix: Document actions showing in cmd-k without context 2026-03-11 19:22:54 -04:00
Tom Moor 89511d4026 fix: MobX warning in BlockMenu 2026-03-11 19:03:15 -04:00
Copilot bd573c44c1 Add ABAP to supported code formatting languages (#11721)
* Initial plan

* Add ABAP as a supported code formatting language

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-11 17:33:32 -04:00
Tom Moor 2e50fb0344 feat: Add toggle for all notifications (#11713)
* feat: Add toggle for all notifications

* tidy
2026-03-11 08:38:12 -04:00
Tom Moor fee9791cc9 Add instructions slide (#11710) 2026-03-11 08:31:21 -04:00
Akshat Singhal e913075d75 Removed usage of ALLOWED_DOMAINS and GOOGLE_ALLOWED_DOMAINS. (#11718) 2026-03-11 08:31:12 -04:00
Ilja Lukin bb3d72cb83 Add German (de_DE) long‑date format to useLocaleTime hook (#11720) 2026-03-11 12:17:39 +00:00
Tom Moor 0d8d9a1798 chore: Move warning logs from Sentry to standard logs (#11708) 2026-03-10 23:37:02 +00:00
Copilot 0c6e4f349b Add FontAwesome icon support for Mermaid diagrams (#11704)
* Initial plan

* Implement FontAwesome icon support for Mermaid diagrams

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-10 19:25:17 -04:00
Copilot a8b701aff3 fix: Correctly strip node comments on duplication (#11700)
* Initial plan

* fix: preserve table row background colors when duplicating documents

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* test

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-03-10 19:25:04 -04:00
Copilot 83977f85bd Use filtered fetch in Figma and Linear plugins (#11701)
* Initial plan

* chore: use filtered fetch in Figma and Linear plugins

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-09 23:04:18 -04:00
Tom Moor 9f1e6d8b40 fix: Increase code block font size (#11690) 2026-03-08 22:07:08 -04:00
Tom Moor 257d01af89 fix: Missing check for enabled passkeys in verification endpoint (#11689) 2026-03-08 18:46:13 -04:00
Tom Moor 1a54625cdb feat: Insert templates from block menu (#11647)
* chore: Move SuggestionsMenu to Radix

* Restore bounce anim

* fix: Clear query on button open

* Sub-menu support

* fix bugs

* PR feedback

* Insert templates from block menu

* refactor
2026-03-08 18:27:04 -04:00
Tom Moor 1a380f844c perf: Avoids instantiating editor extensions not required in read-only (#11681)
* perf: Avoids instantiating editor extensions not required in read-only

* Now class-based extensions are checked via ext.prototype before new ext() is called
2026-03-08 18:26:52 -04:00
Tom Moor 03a78ab6d6 feat: Use web haptics lib (#11685) 2026-03-08 18:26:42 -04:00
Tom Moor b63225fa73 Improve user membership policy check (#11687) 2026-03-08 18:26:33 -04:00
Tom Moor 3066b7ba4e feat: Presentation mode (#11678)
* wip

* fix scaling, query string, icons, refactor

* refactor
2026-03-07 09:17:47 -05:00
Tom Moor aeb6d12f17 fix: Incorrect nesting in document explorer (#11680)
* fix: Incorrect nesting in document explorer

* fix: Disclosure position in explorer
2026-03-07 00:15:51 -05:00
Tom Moor db19a5cf0d fix: Incorrect insertion position of mentions (#11671)
closes #11461
2026-03-05 21:34:14 -05:00
Tom Moor c875930430 fix: Improved resiliency to invalid GitLab data (#11669) 2026-03-05 19:48:17 -05:00
Tom Moor 3d1c2a8759 chore: Remove datadog-metrics lib (#11665)
* chore: Remove datadog-metrics lib

* Restore Pako transient dep types

* PR feedback
2026-03-05 19:27:11 -05:00
Tom Moor 2681a2cfaf feat: Support rendering shared docs as Markdown with .md extension (#11668) 2026-03-05 19:27:03 -05:00
Tom Moor 1b88189f9c fix: INSERT into subscriptions deadlocked transactions (#11667) 2026-03-05 18:53:13 -05:00
Tom Moor ace351035a chore: Move SuggestionsMenu to Radix (#11644)
* chore: Move SuggestionsMenu to Radix

* Restore bounce anim

* fix: Clear query on button open

* Sub-menu support

* fix bugs

* PR feedback
2026-03-05 17:45:19 -05:00
Copilot 3222a77cc3 Remove star control from DocumentListItem on mobile (#11655)
* Initial plan

* Remove star control on DocumentListItem on mobile

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-05 17:45:05 -05:00
Tom Moor d8b0e731ef fix: ESC for back in SharePopover not working (#11662)
* fix: ESC for back in SharePopover not working

closes #11656

* Normalize prevent default callbacks
2026-03-05 17:44:48 -05:00
Apoorv Mishra 80c1e5a10b fix: reset focused comment on drawer close (#11661) 2026-03-05 17:44:39 -05:00
Copilot 5b89882e5e Highlight parent menu item when submenu is open (#11659)
* Initial plan

* fix: highlight parent menu item when sub-menu is open

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-05 13:46:30 +00:00
Translate-O-Tron f85ad1a7e1 New Crowdin updates (#11455)
* fix: New Hebrew translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Korean translations from Crowdin [ci skip]
2026-03-05 08:22:17 -05:00
Salihu 38a3e651a7 fix: Rebuild of sourced permissions on document move (#11229)
* only recalculate permssions for moved document

* stop duplicating permissions

* add tests

* fix comment

* filter duplicates

* filter duplicates

* minor fixes

* fix child document permissions not being recalculated

* expand tests

* Update DocumentMovedProcessor.test.ts

* Update server/queues/processors/DocumentMovedProcessor.test.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* requested changes

* remove all sourced permissions before calculating new ones

* remove all sourced permissions before recalculating

* Add additional tests

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-05 08:22:03 -05:00
280 changed files with 11712 additions and 3510 deletions
+2 -3
View File
@@ -129,9 +129,8 @@ FORCE_HTTPS=true
# –––––––––– AUTHENTICATION ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# Third party signin credentials, at least ONE OF these is required for a
# working installation or you'll have no sign-in options.
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
+3
View File
@@ -1,3 +1,6 @@
nodeLinker: node-modules
npmMinimalAgeGate: 86400
npmPreapprovedPackages:
- outline-icons
+1 -1
View File
@@ -70,7 +70,7 @@ yarn install
### Exports
- Exported members must appear at the top of the file.
- Prefer named exports for components & classes.
- Always use named exports for new components & classes.
- Document ALL public/exported functions with JSDoc.
## React Usage
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.5.0
Licensed Work: Outline 1.6.1
The Licensed Work is (c) 2026 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: 2030-02-15
Change Date: 2030-03-18
Change License: Apache License, Version 2.0
+2 -2
View File
@@ -33,9 +33,9 @@ There is a short guide for [setting up a development environment](https://docs.g
## Contributing
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Outline is built and maintained by a small team your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
+63 -3
View File
@@ -32,6 +32,8 @@ import {
CaseSensitiveIcon,
RestoreIcon,
EditIcon,
EmbedIcon,
OpenIcon,
} from "outline-icons";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
@@ -73,6 +75,7 @@ import {
searchPath,
documentPath,
urlify,
desktopify,
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
@@ -86,6 +89,8 @@ import type {
} from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import env from "~/env";
import { isMac, isWindows } from "@shared/utils/browser";
import isCloudHosted from "~/utils/isCloudHosted";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
@@ -335,8 +340,15 @@ export const createNewDocument = createActionWithChildren({
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return (
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
@@ -565,7 +577,10 @@ export const shareDocument = createAction({
section: ActiveDocumentSection,
icon: <PadlockIcon />,
visible: ({ stores, activeDocumentId }) => {
const can = stores.policies.abilities(activeDocumentId!);
if (!activeDocumentId) {
return false;
}
const can = stores.policies.abilities(activeDocumentId);
return can.manageUsers || can.share;
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -944,6 +959,49 @@ export const printDocument = createAction({
},
});
export const openDocumentInDesktop = createAction({
name: ({ t }) => t("Open in desktop app"),
analyticsName: "Open in desktop",
section: ActiveDocumentSection,
icon: <OpenIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
);
},
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
window.location.href = desktopify(documentPath(document));
}
},
});
export const presentDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")),
analyticsName: "Present document",
section: ActiveDocumentSection,
icon: <EmbedIcon />,
shortcut: ["Meta+Alt+p"],
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
stores.ui.setPresentingDocument(document);
},
});
export const importDocument = createAction({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
@@ -1487,11 +1545,13 @@ export const rootDocumentActions = [
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
presentDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
shareDocument,
];
+8 -6
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, Redirect } from "react-router-dom";
import { Switch, Route } from "react-router-dom";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
@@ -57,15 +57,17 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
history.push(newDocumentPath(activeCollectionId));
};
React.useEffect(() => {
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
history.replace(postLoginPath);
}
}, [spendPostLoginPath]);
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
+7
View File
@@ -3,6 +3,8 @@ import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import type { HapticInput } from "web-haptics";
import { useWebHaptics } from "web-haptics/react";
import { s } from "@shared/styles";
import type { Props as ActionButtonProps } from "~/components/ActionButton";
import ActionButton from "~/components/ActionButton";
@@ -152,6 +154,8 @@ export type Props<T> = ActionButtonProps & {
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
haptic?: HapticInput;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
@@ -176,11 +180,13 @@ const Button = <T extends React.ElementType = "button">(
hideIcon,
fullwidth,
danger,
haptic,
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : (action?.icon ?? icon);
const hasIcon = ic !== undefined;
const { trigger } = useWebHaptics();
return (
<RealButton
@@ -191,6 +197,7 @@ const Button = <T extends React.ElementType = "button">(
$danger={danger}
$fullwidth={fullwidth}
$borderOnHover={borderOnHover}
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
{...rest}
>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
+91 -40
View File
@@ -6,8 +6,8 @@ import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import type { CollectionPermission } from "@shared/types";
import { TeamPreference } from "@shared/types";
import { CollectionPermission, TeamPreference } from "@shared/types";
import type { Option } from "~/components/InputSelect";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
@@ -15,6 +15,7 @@ import type Collection from "~/models/Collection";
import Button from "~/components/Button";
import { Collapsible } from "~/components/Collapsible";
import Input from "~/components/Input";
import { InputSelect } from "~/components/InputSelect";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
@@ -34,6 +35,7 @@ export interface FormData {
sharing: boolean;
permission: CollectionPermission | undefined;
commenting?: boolean | null;
templateManagement: CollectionPermission;
}
const useIconColor = (collection?: Collection) => {
@@ -68,6 +70,22 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const templateManagementOptions = useMemo<Option[]>(
() => [
{
type: "item",
label: t("Managers"),
value: CollectionPermission.Admin,
},
{
type: "item",
label: t("Members"),
value: CollectionPermission.ReadWrite,
},
],
[t]
);
const iconColor = useIconColor(collection);
const fallbackIcon = (
<Icon
@@ -93,6 +111,8 @@ export const CollectionForm = observer(function CollectionForm_({
sharing: collection?.sharing ?? true,
permission: collection?.permission,
commenting: collection?.commenting ?? true,
templateManagement:
collection?.templateManagement ?? CollectionPermission.Admin,
color: iconColor,
},
});
@@ -135,6 +155,71 @@ export const CollectionForm = observer(function CollectionForm_({
const initial = values.name.charAt(0).toUpperCase();
const options = (
<>
<Controller
control={control}
name="templateManagement"
render={({ field }) => (
<>
<InputSelect
value={field.value}
onChange={(value: string) => {
field.onChange(value as CollectionPermission);
}}
options={templateManagementOptions}
label={t("Manage templates")}
/>
<Text
type="secondary"
size="small"
as="p"
style={{ paddingTop: 4 }}
>
{t(
"Choose who can create and edit templates in this collection."
)}
</Text>
</>
)}
/>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
</>
);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
@@ -190,44 +275,10 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
<Collapsible label={t("Advanced options")}>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t(
"Allow commenting on documents within this collection."
)}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
</Collapsible>
{collection ? (
options
) : (
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
)}
<HStack justify="flex-end">
+8 -1
View File
@@ -128,7 +128,14 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
React.useEffect(() => {
if (contentRef.current && value !== contentRef.current.textContent) {
setInnerValue(value);
if (document.activeElement === contentRef.current) {
// Don't reset content while the user is actively editing. Update
// lastValue so that the next input or blur event will push the
// current DOM text back to the model via onChange.
lastValue.current = value;
} else {
setInnerValue(value);
}
}
}, [value, contentRef]);
@@ -12,7 +12,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
@@ -42,6 +41,28 @@ type Props = {
showDocuments?: boolean;
};
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
function DocumentExplorer({
onSubmit,
onSelect,
@@ -67,8 +88,6 @@ function DocumentExplorer({
return node || null;
}
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
@@ -91,9 +110,6 @@ function DocumentExplorer({
);
const listRef = React.useRef<List<NavigationNode[]>>(null);
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const searchIndex = React.useMemo(
() =>
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
@@ -144,7 +160,8 @@ function DocumentExplorer({
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, [defaultValue, selectedNode, nodes]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
@@ -152,17 +169,9 @@ function DocumentExplorer({
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
if (itemRefs[node] && itemRefs[node].current) {
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
behavior: "auto",
block: "center",
});
}
},
[itemRefs]
);
const scrollNodeIntoView = React.useCallback((node: number) => {
listRef.current?.scrollToItem(node, "smart");
}, []);
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(ev.target.value);
@@ -170,16 +179,16 @@ function DocumentExplorer({
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
const calculateInitialScrollOffset = (itemCount: number) => {
const preserveScrollOffset = (itemCount: number) => {
if (listRef.current) {
const { height, itemSize } = listRef.current.props;
const { scrollOffset } = listRef.current.state as {
scrollOffset: number;
};
const itemsHeight = itemCount * itemSize;
return itemsHeight < Number(height) ? 0 : scrollOffset;
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
setTimeout(() => listRef.current?.scrollTo(offset), 0);
}
return 0;
};
const collapse = (node: number) => {
@@ -190,8 +199,7 @@ function DocumentExplorer({
// remove children
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
preserveScrollOffset(newNodes.length);
};
const expand = (node: number) => {
@@ -200,8 +208,7 @@ function DocumentExplorer({
// add children
const newNodes = nodes.slice();
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
preserveScrollOffset(newNodes.length);
};
React.useEffect(() => {
@@ -225,7 +232,8 @@ function DocumentExplorer({
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 || showDocuments !== false;
nodes[node].children.length > 0 ||
(showDocuments !== false && nodes[node].type === "collection");
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -387,25 +395,6 @@ function DocumentExplorer({
}
};
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
return (
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
<ListSearch
@@ -425,14 +414,12 @@ function DocumentExplorer({
<Flex role="listbox" column>
<List
ref={listRef}
key={nodes.length}
width={width}
height={height}
itemData={nodes}
itemCount={nodes.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results) => results[index].id}
>
{ListItem}
@@ -40,10 +40,8 @@ function DocumentExplorerNode(
ref: React.RefObject<HTMLSpanElement>
) {
const { t } = useTranslation();
const OFFSET = 12;
const DISCLOSURE = 20;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
const DISCLOSURE = 24;
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
return (
<Node
@@ -80,7 +78,7 @@ const Title = styled(Text)`
const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin-top: 2px;
margin: 2px 0;
`;
const Spacer = styled(Flex)<{ width: number }>`
@@ -1,7 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "./DocumentExplorerNode";
@@ -32,22 +31,8 @@ function DocumentExplorerSearchResult({
}: Props) {
const { t } = useTranslation();
const ref = React.useCallback(
(node: HTMLSpanElement | null) => {
if (active && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
});
}
},
[active]
);
return (
<SearchResult
ref={ref}
selected={selected}
active={active}
onClick={onClick}
+3 -1
View File
@@ -22,6 +22,7 @@ import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
@@ -58,6 +59,7 @@ function DocumentListItem(
const { userMemberships, groupMemberships } = useStores();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isMobile = useMobile();
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
@@ -159,7 +161,7 @@ function DocumentListItem(
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && <StarButton document={document} />}
{canStar && !isMobile && <StarButton document={document} />}
</Heading>
{!queryIsInTitle && (
+1
View File
@@ -88,6 +88,7 @@ function Header(
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
haptic="light"
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
+4 -6
View File
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
white-space: nowrap;
`;
export const Description = styled(StyledText)`
export const Description = styled(StyledText)<{ $margin?: string }>`
${sharedVars}
margin-top: 0.5em;
margin-top: ${(props) => props.$margin ?? "0.5em"};
line-height: var(--line-height);
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
overflow: hidden;
@@ -64,8 +64,6 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
width: fit-content;
border-radius: 2em;
padding: 1px 8px 1px 20px;
margin-right: 0.5em;
margin-top: 0.5em;
position: relative;
flex-shrink: 0;
@@ -75,8 +73,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${(props) =>
props.color || props.theme.backgroundSecondary};
@@ -17,6 +17,7 @@ import HoverPreviewGroup from "./HoverPreviewGroup";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewProject from "./HoverPreviewProject";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 500;
@@ -192,6 +193,18 @@ const HoverPreviewDesktop = observer(
createdAt={data.createdAt}
state={data.state}
/>
) : data.type === UnfurlResourceType.Project ? (
<HoverPreviewProject
ref={cardRef}
url={data.url}
name={data.name}
color={data.color}
lead={data.lead}
labels={data.labels}
description={data.description}
state={data.state}
targetDate={data.targetDate}
/>
) : (
<HoverPreviewLink
ref={cardRef}
@@ -75,7 +75,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
</Description>
)}
<Flex wrap>
<Flex wrap gap={6} style={{ marginTop: 8 }}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
@@ -0,0 +1,148 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Backticks } from "@shared/components/Backticks";
import Squircle from "@shared/components/Squircle";
import Editor from "~/components/Editor";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Card,
CardContent,
Label,
Description,
} from "./Components";
import { richExtensions } from "@shared/editor/nodes";
type Props = Pick<
UnfurlResponse[UnfurlResourceType.Project],
| "url"
| "name"
| "color"
| "lead"
| "labels"
| "state"
| "targetDate"
| "description"
>;
const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_(
{ url, name, color, lead, labels, state, description, targetDate }: Props,
ref: React.Ref<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={4} column>
<Title>
<StyledSquircle color={color} size={16} />
<span>
<Backticks content={name} />
</span>
</Title>
{description && (
<Description as="div" $margin="0">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Text
type="tertiary"
size="small"
style={{ textTransform: "capitalize" }}
>
{state.name}
</Text>
{(lead || targetDate) && (
<>
<Divider />
{lead && (
<MetadataRow>
<MetadataLabel>{t("Lead")}</MetadataLabel>
<Flex align="center" gap={6}>
<Avatar src={lead.avatarUrl} size={AvatarSize.Toast} />
<Text size="small">{lead.name}</Text>
</Flex>
</MetadataRow>
)}
{targetDate && (
<MetadataRow>
<MetadataLabel>{t("Target date")}</MetadataLabel>
<Text size="small">
<Time dateTime={targetDate} addSuffix />
</Text>
</MetadataRow>
)}
</>
)}
{labels.length > 0 && (
<>
<Divider />
<MetadataRow>
<MetadataLabel>{t("Labels")}</MetadataLabel>
<Flex wrap gap={6}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
</Label>
))}
</Flex>
</MetadataRow>
</>
)}
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
});
const StyledSquircle = styled(Squircle)`
flex-shrink: 0;
margin-top: 4px;
`;
const Divider = styled.div`
height: 1px;
background: ${s("divider")};
margin: 4px 0;
`;
const MetadataRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
`;
const MetadataLabel = styled(Text).attrs({
type: "tertiary",
size: "small",
})`
flex-shrink: 0;
min-width: 80px;
`;
export default HoverPreviewProject;
+22 -17
View File
@@ -9,39 +9,44 @@ export interface LazyComponent<T extends React.ComponentType<any>> {
interface LazyLoadOptions {
retries?: number;
interval?: number;
/** If provided, picks this named export from the module instead of `default`. */
exportName?: string;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
* Supports both default and named exports.
*
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
* @param factory A function that returns a promise of a module.
* @param options Optional configuration for retry behavior and export name.
* @returns An object containing the lazy Component and a preload function.
*
* @example
* ```typescript
* // Default export
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* // Named export
* const MyComponent = createLazyComponent(() => import('./MyComponent'), {
* exportName: 'MyComponent',
* });
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
factory: () => Promise<Record<string, T>>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval } = options;
const { retries, interval, exportName } = options;
const wrappedFactory = exportName
? () =>
factory().then((m) => ({
default: m[exportName],
}))
: (factory as () => Promise<{ default: T }>);
return {
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
Component: lazyWithRetry(wrappedFactory, retries, interval),
preload: wrappedFactory,
};
}
+2 -6
View File
@@ -3,6 +3,7 @@ import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import type { ActionVariant, ActionWithChildren } from "~/types";
import { preventDefault } from "~/utils/events";
import { toMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
@@ -61,11 +62,6 @@ export const ContextMenu = observer(
}
}, []);
const handleCloseAutoFocus = React.useCallback(
(e: Event) => e.preventDefault(),
[]
);
if (isMobile || !action || menuItems.length === 0) {
return <>{children}</>;
}
@@ -80,7 +76,7 @@ export const ContextMenu = observer(
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
onCloseAutoFocus={preventDefault}
>
{content}
</MenuContent>
+2 -6
View File
@@ -13,6 +13,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { preventDefault } from "~/utils/events";
import type {
ActionVariant,
ActionWithChildren,
@@ -98,11 +99,6 @@ export const DropdownMenu = observer(
}
}, []);
const handleCloseAutoFocus = React.useCallback(
(e: Event) => e.preventDefault(),
[]
);
if (isMobile) {
return (
<MobileDropdown
@@ -129,7 +125,7 @@ export const DropdownMenu = observer(
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
onCloseAutoFocus={preventDefault}
>
{content}
{append}
+32
View File
@@ -0,0 +1,32 @@
import { observer } from "mobx-react";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const PresentationMode = lazyWithRetry(
() => import("~/scenes/Document/components/PresentationMode")
);
function Presentation() {
const { ui } = useStores();
if (!ui.presentationData) {
return null;
}
return (
<Suspense fallback={null}>
<PresentationMode
title={ui.presentationData.title}
icon={ui.presentationData.icon}
iconColor={ui.presentationData.color}
data={ui.presentationData.data}
onClose={() => {
ui.setPresentingDocument(null);
}}
/>
</Suspense>
);
}
export default observer(Presentation);
+4 -1
View File
@@ -1,4 +1,5 @@
import { useKBar } from "kbar";
import { observer } from "mobx-react";
import { useEffect, useRef } from "react";
import { Minute } from "@shared/utils/time";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
@@ -14,7 +15,7 @@ interface CacheEntry {
// Cache configuration
const cacheTTL = Minute.ms * 5;
export default function SearchActions() {
function SearchActions() {
const { searches, documents } = useStores();
// Cache structure: Map of search queries to timestamp of last search
@@ -58,3 +59,5 @@ export default function SearchActions() {
return null;
}
export default observer(SearchActions);
+2 -1
View File
@@ -16,6 +16,7 @@ import {
import { id as bodyContentId } from "~/components/SkipNavContent";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import type { SearchResult } from "~/types";
import SearchListItem from "./SearchListItem";
@@ -230,7 +231,7 @@ function SearchPopover({ shareId, className }: Props) {
align="start"
shrink
onEscapeKeyDown={handleEscapeList}
onOpenAutoFocus={(e) => e.preventDefault()}
onOpenAutoFocus={preventDefault}
onInteractOutside={(event) => {
const target = event.target as Element | null;
if (target === searchInputRef.current) {
@@ -89,7 +89,11 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
const members = React.useMemo(
() =>
orderBy(
document.members,
Array.from(
new Map(
document.members.map((memberUser) => [memberUser.id, memberUser])
).values()
),
(memberUser) =>
(invitedInSession.includes(memberUser.id) ? "_" : "") +
memberUser.name.toLocaleLowerCase(),
@@ -124,12 +128,19 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
return (
<>
{groupMemberships
.inDocument(document.id)
{Array.from(
new Map(
groupMemberships
.inDocument(document.id)
.map((membership) => [membership.group.id, membership])
).values()
)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
).localeCompare(b.group.name)
).localeCompare(
(invitedInSession.includes(b.group.id) ? "_" : "") + b.group.name
)
)
.map((membership) => {
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
+2 -1
View File
@@ -76,7 +76,8 @@ function SettingsSidebar() {
to={item.path}
onClickIntent={item.preload}
active={
item.path.startsWith(settingsPath("templates"))
item.path.startsWith(settingsPath("templates")) ||
item.path.startsWith(settingsPath("groups"))
? location.pathname.startsWith(item.path)
: undefined
}
+8 -1
View File
@@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { useLocation } from "react-router-dom";
import styled, { css, useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -53,6 +54,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
const collapsed = ui.sidebarIsClosed && canCollapse;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const { trigger } = useWebHaptics();
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
@@ -224,6 +226,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
[width]
);
const handleCloseSidebar = () => {
trigger("light");
ui.toggleMobileSidebar();
};
return (
<TooltipProvider>
<Container
@@ -275,7 +282,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
{ui.mobileSidebarVisible && <Backdrop onClick={handleCloseSidebar} />}
</TooltipProvider>
);
});
@@ -152,7 +152,7 @@ function SidebarLink(
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
style={style}
style={active ? activeStyle : style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
onActiveClick={handleDisclosureClick}
+18 -1
View File
@@ -1,11 +1,28 @@
import { observer } from "mobx-react";
import { Toaster } from "sonner";
import * as React from "react";
import { Toaster, useSonner } from "sonner";
import styled, { useTheme } from "styled-components";
import { useWebHaptics } from "web-haptics/react";
import useStores from "~/hooks/useStores";
function Toasts() {
const { ui } = useStores();
const theme = useTheme();
const { toasts } = useSonner();
const { trigger } = useWebHaptics();
const prevCountRef = React.useRef(toasts.length);
React.useEffect(() => {
if (toasts.length > prevCountRef.current) {
const latest = toasts[toasts.length - 1];
if (latest.type === "error") {
void trigger("error");
} else if (latest.type === "success") {
void trigger("success");
}
}
prevCountRef.current = toasts.length;
}, [toasts, trigger]);
return (
<StyledToaster
+2 -2
View File
@@ -2,7 +2,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { transparentize } from "polished";
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { s } from "@shared/styles";
import { s, depths } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
@@ -267,7 +267,7 @@ const StyledContent = styled(TooltipPrimitive.Content)`
white-space: normal;
outline: 0;
padding: 5px 9px;
z-index: 9999;
z-index: ${depths.tooltip};
max-width: calc(100vw - 10px);
/* Animation */
+1 -1
View File
@@ -129,7 +129,7 @@ const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
`}
&[data-state="open"] {
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1); // ease-out-circ
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1);
}
`;
@@ -67,6 +67,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
!props.disabled &&
`
&[data-highlighted],
&[data-state="open"],
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
+113 -3
View File
@@ -1,17 +1,126 @@
import { useCallback } from "react";
import { DocumentIcon, ShapesIcon } from "outline-icons";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import type { MenuItem } from "@shared/editor/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import useCurrentUser from "~/hooks/useCurrentUser";
import useDictionary from "~/hooks/useDictionary";
import useStores from "~/hooks/useStores";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
/**
* Hook that returns a template menu item with children for inserting template
* content into the editor, or undefined if no templates are available.
*/
function useTemplateMenuItem(): MenuItem | undefined {
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const { documents, templates: templatesStore } = useStores();
const editor = useEditor();
const documentId = editor.props.id;
const document = documentId ? documents.get(documentId) : undefined;
const collectionId = document?.collectionId;
return useMemo(() => {
if (!user) {
return undefined;
}
const allTemplates = templatesStore.orderedData.filter(
(template) => template.isActive
);
const hasTemplates = allTemplates.some(
(template) =>
template.isWorkspaceTemplate || template.collectionId === collectionId
);
if (!hasTemplates) {
return undefined;
}
const toMenuItem = (template: (typeof allTemplates)[0]): MenuItem => ({
name: "noop",
title: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
user
),
icon: template.icon ? (
<Icon
value={template.icon}
initial={template.initial}
color={template.color ?? undefined}
/>
) : (
<DocumentIcon />
),
keywords: template.titleWithDefault,
onClick: () => {
const data = cloneDeep(template.data);
ProsemirrorHelper.replaceTemplateVariables(data, user);
editor.insertContent(data);
},
});
const children = (): MenuItem[] => {
const collectionTemplates = allTemplates.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === collectionId
);
const workspaceTemplates = allTemplates.filter(
(tmpl) => tmpl.isWorkspaceTemplate
);
const items: MenuItem[] = collectionTemplates.map(toMenuItem);
if (collectionTemplates.length && workspaceTemplates.length) {
items.push({ name: "separator" });
}
if (workspaceTemplates.length) {
for (const template of workspaceTemplates) {
items.push(toMenuItem(template));
}
}
return items;
};
return {
name: "noop",
title: t("Templates"),
icon: <ShapesIcon />,
keywords: "template",
children,
} satisfies MenuItem;
}, [user, templatesStore.orderedData, collectionId, editor, t]);
}
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
Required<Pick<SuggestionsMenuProps, "embeds">>;
function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
const templateMenuItem = useTemplateMenuItem();
const items = useMemo(() => {
const baseItems = getMenuItems(dictionary, elementRef);
if (!templateMenuItem) {
return baseItems;
}
return [...baseItems, { name: "separator" } as MenuItem, templateMenuItem];
}, [dictionary, elementRef, templateMenuItem]);
const renderMenuItem = useCallback(
(item, _index, options) => (
@@ -20,6 +129,7 @@ function BlockMenu(props: Props) {
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
disclosure={options.disclosure}
/>
),
[]
@@ -31,9 +141,9 @@ function BlockMenu(props: Props) {
filterable
trigger="/"
renderMenuItem={renderMenuItem}
items={getMenuItems(dictionary, elementRef)}
items={items}
/>
);
}
export default BlockMenu;
export default observer(BlockMenu);
+24 -1
View File
@@ -1,7 +1,12 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import {
DocumentIcon,
PlusIcon,
NewDocumentIcon,
CollectionIcon,
} from "outline-icons";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -227,6 +232,24 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
label: search,
},
} as MentionItem,
{
name: "link",
icon: <NewDocumentIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a nested doc"),
visible: !!search && !isEmail(search) && !!documentId,
priority: -2,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
actorId,
label: search,
nested: true,
},
} as MentionItem,
])
: [];
+8 -5
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import type { EmbedDescriptor } from "@shared/editor/embeds";
import type { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { isUrl } from "@shared/utils/urls";
import { isInternalUrl, isUrl } from "@shared/utils/urls";
import type Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -67,6 +67,7 @@ function useItems({
const singleUrl =
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
const isInternal = singleUrl ? isInternalUrl(singleUrl) : false;
const matchedEmbed = singleUrl
? getMatchingEmbed(embeds, singleUrl)?.embed
: null;
@@ -74,7 +75,7 @@ function useItems({
// Check embeddability for single URL
useEffect(() => {
if (!singleUrl || !embed) {
if (!singleUrl || !embed || isInternal) {
setEmbedCheck({ loading: false });
return;
}
@@ -101,7 +102,7 @@ function useItems({
return () => {
cancelled = true;
};
}, [singleUrl, embed]);
}, [singleUrl, embed, isInternal]);
// single item is pasted.
if (typeof pastedText === "string") {
@@ -143,8 +144,10 @@ function useItems({
name: "embed",
title: t("Embed"),
subtitle:
embedCheck.embeddable === false ? t("Not supported") : undefined,
disabled: embedCheck.loading || !embedCheck.embeddable,
embedCheck.embeddable === false || isInternal
? t("Not supported")
: undefined,
disabled: isInternal || embedCheck.loading || !embedCheck.embeddable,
icon: embed?.icon,
keywords: embed?.keywords,
},
+450 -244
View File
@@ -6,21 +6,26 @@ import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import styled, { keyframes } from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import type { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { Portal } from "~/components/Portal";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "~/components/primitives/Popover";
import { MouseSafeArea } from "~/components/MouseSafeArea";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import useMobile from "~/hooks/useMobile";
@@ -29,38 +34,6 @@ import { useEditor } from "./EditorContext";
import Input from "./Input";
import { MenuHeader } from "~/components/primitives/components/Menu";
type TopAnchor = {
top: number;
bottom: undefined;
};
type BottomAnchor = {
top: undefined;
bottom: number;
};
type LeftAnchor = {
left: number;
right: undefined;
};
type RightAnchor = {
left: undefined;
right: number;
};
type Position = ((TopAnchor | BottomAnchor) & (LeftAnchor | RightAnchor)) & {
isAbove: boolean;
};
const defaultPosition: Position = {
top: 0,
bottom: undefined,
left: -10000,
right: undefined,
isAbove: false,
};
export type Props<T extends MenuItem = MenuItem> = {
rtl: boolean;
isActive: boolean;
@@ -80,6 +53,7 @@ export type Props<T extends MenuItem = MenuItem> = {
index: number,
options: {
selected: boolean;
disclosure?: boolean;
onClick: (event: React.SyntheticEvent) => void;
}
) => React.ReactNode;
@@ -92,25 +66,70 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const dictionary = useDictionary();
const { t } = useTranslation();
const isMobile = useMobile();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
clientY: 0,
});
const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
const [position, setPosition] = React.useState<Position>(defaultPosition);
const [insertItem, setInsertItem] = React.useState<
MenuItem | EmbedDescriptor
>();
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [submenu, setSubmenu] = React.useState<{
index: number;
items: MenuItem[];
selectedIndex: number;
} | null>(null);
const itemRefs = React.useRef<Map<number, HTMLElement>>(new Map());
const submenuContentRef = React.useRef<HTMLDivElement>(null);
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
// Stores the caret bounding rect, snapshotted when the menu opens
const caretRectRef = React.useRef(new DOMRect());
// Stable virtual element for Radix PopoverAnchor never replaced so the
// popper does not trigger unnecessary anchor-change cycles.
const caretRef = React.useRef({
getBoundingClientRect: () => caretRectRef.current,
});
// Compute and store the caret rect during render so it is available before
// the Radix popper effect runs for the first time.
const caretRect = React.useMemo(() => {
if (!props.isActive) {
return new DOMRect();
}
try {
const { selection } = view.state;
const fromPos = view.coordsAtPos(selection.from);
const toPos = view.coordsAtPos(selection.to, -1);
const top = Math.min(fromPos.top, toPos.top);
const bottom = Math.max(fromPos.bottom, toPos.bottom);
const left = Math.min(fromPos.left, toPos.left);
const right = Math.max(fromPos.right, toPos.right);
return new DOMRect(left, top, right - left, bottom - top);
} catch (err) {
Logger.warn("Unable to calculate caret position", err);
return new DOMRect();
}
}, [props.isActive, view]);
caretRectRef.current = caretRect;
const resolveChildren = (
children: MenuItem["children"]
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
React.useEffect(() => {
if (props.isActive) {
hasActivated.current = true;
// Save the selection position when the menu opens. On mobile, the editor
// may lose focus/selection when tapping on menu items, so we restore it.
// Save the selection position when the menu opens and as the user types.
// On mobile, the editor may lose focus/selection when tapping on menu
// items, so we restore it. The position must stay current as the search
// text grows, otherwise the deletion range calculated in handleClearSearch
// will be wrong.
requestAnimationFrame(() => {
const { from, to } = view.state.selection;
selectionRef.current = { from, to };
@@ -119,83 +138,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
selectionRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isActive, props.search]);
React.useEffect(() => {
setSubmenu(null);
if (!props.isActive) {
return;
}
setSelectedIndex(0);
setInsertItem(undefined);
}, [props.isActive]);
const calculatePosition = React.useCallback(
(props: Props) => {
if (!props.isActive) {
return defaultPosition;
}
const caretPosition = () => {
let fromPos;
let toPos;
try {
fromPos = view.coordsAtPos(selection.from);
toPos = view.coordsAtPos(selection.to, -1);
} catch (err) {
Logger.warn("Unable to calculate caret position", err);
return {
top: 0,
bottom: 0,
left: 0,
right: 0,
};
}
// ensure that start < end for the menu to be positioned correctly
return {
top: Math.min(fromPos.top, toPos.top),
bottom: Math.max(fromPos.bottom, toPos.bottom),
left: Math.min(fromPos.left, toPos.left),
right: Math.max(fromPos.right, toPos.right),
};
};
const { selection } = view.state;
const ref = menuRef.current;
const offsetWidth = ref ? ref.offsetWidth : 0;
const offsetHeight = ref ? ref.offsetHeight : 0;
const { top, bottom, right, left } = caretPosition();
const margin = 12;
const offsetParent = ref?.offsetParent
? ref.offsetParent.getBoundingClientRect()
: ({
width: 0,
height: 0,
top: 0,
left: 0,
} as DOMRect);
let leftPos = Math.min(
left - offsetParent.left,
window.innerWidth - offsetParent.left - offsetWidth - margin
);
if (props.rtl) {
leftPos = right - offsetWidth;
}
if (top - offsetHeight > margin) {
return {
left: leftPos,
top: undefined,
bottom: offsetParent.bottom - top,
right: undefined,
isAbove: false,
};
} else {
return {
left: leftPos,
top: bottom - offsetParent.top,
bottom: undefined,
right: undefined,
isAbove: true,
};
}
},
[view]
);
React.useEffect(() => {
setSelectedIndex(0);
setSubmenu(null);
}, [props.search]);
const handleClearSearch = React.useCallback(() => {
const { state, dispatch } = view;
@@ -226,26 +185,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
);
}, [props.search, props.trigger, view]);
React.useEffect(() => {
if (!props.isActive) {
return;
}
// reset scroll position to top when opening menu as the contents are
// hidden, not unrendered
if (menuRef.current) {
menuRef.current.scroll({ top: 0 });
}
setPosition(calculatePosition(props));
setSelectedIndex(0);
setInsertItem(undefined);
}, [calculatePosition, props.isActive]);
React.useEffect(() => {
setSelectedIndex(0);
}, [props.search]);
const restoreSelection = React.useCallback(() => {
if (!isMobile) {
return;
@@ -274,7 +213,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
if (item.name === "noop") {
// Do nothing
if ("onClick" in item) {
item.onClick?.();
}
} else if (command) {
command(attrs);
} else {
@@ -304,10 +245,13 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
...item,
name: "mention",
});
void editorProps.onCreateLink?.({
title: item.attrs.label,
id: item.attrs.modelId,
});
void editorProps.onCreateLink?.(
{
title: item.attrs.label,
id: item.attrs.modelId,
},
!!item.attrs.nested
);
return;
case "image":
return triggerFilePick(
@@ -481,11 +425,47 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
const searchInput = search.toLowerCase();
const matchesSearch = (item: MenuItem | EmbedDescriptor) =>
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
(item.keywords || "").toLocaleLowerCase().includes(searchInput);
// When searching, flatten matching children into the top-level list so
// they are directly navigable with the keyboard. If all children match,
// exclude the parent item since it would be redundant.
const fullyFlattenedParents = new Set<MenuItem | EmbedDescriptor>();
if (search && filterable) {
const flattened: (EmbedDescriptor | MenuItem)[] = [];
for (const item of items) {
if ("children" in item && item.children) {
const children = resolveChildren(item.children);
if (children) {
const matching = children.filter(matchesSearch);
if (matching.length > 0) {
for (const child of matching) {
const { children: _, ...flat } = child;
flattened.push(flat);
}
if (matching.length === children.length) {
fullyFlattenedParents.add(item);
}
}
}
}
}
items = items.concat(flattened);
}
const filtered = items.filter((item) => {
if (item.name === "separator") {
return true;
}
if (fullyFlattenedParents.has(item)) {
return false;
}
if (item.visible === false) {
return false;
}
@@ -514,11 +494,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return item;
}
return (
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
(item.keywords || "").toLocaleLowerCase().includes(searchInput)
);
return matchesSearch(item);
});
return filterExcessSeparators(
@@ -541,18 +517,40 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
);
}, [commands, props]);
React.useEffect(() => {
const handleMouseDown = (event: MouseEvent) => {
if (
!menuRef.current ||
menuRef.current.contains(event.target as Element)
) {
const openSubmenu = React.useCallback(
(index: number) => {
const item = filtered[index];
if (!item) {
return;
}
const children = resolveChildren(
"children" in item ? item.children : undefined
);
if (!children?.length) {
return;
}
props.onClose();
};
const normalized = filterExcessSeparators(
children.filter((child) => child.visible !== false)
);
const firstSelectable = normalized.findIndex(
(child) =>
child.name !== "separator" && !("disabled" in child && child.disabled)
);
if (firstSelectable === -1) {
return;
}
setSubmenu({
index,
items: normalized,
selectedIndex: firstSelectable,
});
},
[filtered]
);
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing) {
return;
@@ -561,18 +559,109 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return;
}
// Let the link input's own handlers manage navigation keys
if (insertItem) {
return;
}
// --- Submenu open: route keys into it ---
if (submenu) {
if (event.key === "ArrowDown" || (event.ctrlKey && event.key === "n")) {
event.preventDefault();
event.stopPropagation();
const total = submenu.items.length - 1;
let next = submenu.selectedIndex + 1;
while (next <= total) {
const child = submenu.items[next];
if (
child?.name !== "separator" &&
!("disabled" in child && child.disabled)
) {
break;
}
next++;
}
if (next <= total) {
setSubmenu((s) => (s ? { ...s, selectedIndex: next } : s));
}
return;
}
if (event.key === "ArrowUp" || (event.ctrlKey && event.key === "p")) {
event.preventDefault();
event.stopPropagation();
let prev = submenu.selectedIndex - 1;
while (prev >= 0) {
const child = submenu.items[prev];
if (
child?.name !== "separator" &&
!("disabled" in child && child.disabled)
) {
break;
}
prev--;
}
if (prev >= 0) {
setSubmenu((s) => (s ? { ...s, selectedIndex: prev } : s));
}
return;
}
if (event.key === "ArrowLeft" || event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setSubmenu(null);
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
const child = submenu.items[submenu.selectedIndex];
if (child) {
handleClickItem(child);
setSubmenu(null);
}
return;
}
return;
}
// --- Normal (no submenu) ---
if (event.key === "Enter") {
event.preventDefault();
const item = filtered[selectedIndex];
if (item) {
handleClickItem(item);
const children = resolveChildren(
"children" in item ? item.children : undefined
);
if (children?.length) {
openSubmenu(selectedIndex);
} else {
handleClickItem(item);
}
} else {
props.onClose(true);
}
}
if (event.key === "ArrowRight") {
const item = filtered[selectedIndex];
if (item) {
const children = resolveChildren(
"children" in item ? item.children : undefined
);
if (children?.length) {
event.preventDefault();
event.stopPropagation();
openSubmenu(selectedIndex);
return;
}
}
}
if (
event.key === "ArrowUp" ||
(event.key === "Tab" && event.shiftKey) ||
@@ -636,18 +725,25 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
};
window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("keydown", handleKeyDown, {
capture: true,
});
return () => {
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("keydown", handleKeyDown, {
capture: true,
});
};
}, [close, filtered, handleClickItem, props, selectedIndex]);
}, [
close,
filtered,
handleClickItem,
insertItem,
openSubmenu,
props,
selectedIndex,
submenu,
]);
const { isActive, uploadFile } = props;
const items = filtered;
@@ -664,7 +760,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const fileInput = uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Import document</Trans>
<Trans>Upload file</Trans>
<input
type="file"
ref={inputRef}
@@ -675,6 +771,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
</VisuallyHidden.Root>
);
// Close submenu when parent selection moves away from the trigger
React.useEffect(() => {
if (submenu && submenu.index !== selectedIndex) {
setSubmenu(null);
}
}, [selectedIndex, submenu]);
// Cleanup hover timer on unmount
React.useEffect(
() => () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
},
[]
);
const renderItems = () => {
let prevHeading: string | undefined;
@@ -693,6 +806,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return null;
}
const hasChildren = !!(
"children" in item && resolveChildren(item.children)?.length
);
const handlePointerMove = (ev: React.PointerEvent) => {
if (
!("disabled" in item && item.disabled) &&
@@ -708,6 +825,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
clientX: ev.clientX,
clientY: ev.clientY,
};
// Hover to open submenu with delay
if (hasChildren) {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
hoverTimerRef.current = setTimeout(() => {
openSubmenu(index);
}, 150);
} else {
// Close submenu when hovering a regular item
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
setSubmenu(null);
}
};
const handlePointerDown = () => {
@@ -722,23 +855,37 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleOnClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(item);
if (hasChildren) {
openSubmenu(index);
} else {
handleClickItem(item);
}
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const itemRef = (node: HTMLElement | null) => {
if (node) {
itemRefs.current.set(index, node);
} else {
itemRefs.current.delete(index);
}
};
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== prevHeading && (
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
)}
<ListItem
ref={itemRef}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
disclosure: hasChildren,
onClick: handleOnClick,
})}
</ListItem>
@@ -792,37 +939,146 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
return (
<Portal>
<Wrapper active={isActive} ref={menuRef} hiddenScrollbars {...position}>
{(isActive || hasActivated.current) && (
<>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
"placeholder" in insertItem && !!insertItem.placeholder
? insertItem.placeholder
: insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
<>
<Popover open={isActive} onOpenChange={handleOpenChange} modal={false}>
<PopoverAnchor virtualRef={caretRef} />
<BouncyPopoverContent
side="bottom"
align="start"
width={280}
shrink
style={{
padding: 0,
maxHeight:
"min(324px, var(--radix-popover-content-available-height))",
}}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (submenuContentRef.current?.contains(e.target as Node)) {
e.preventDefault();
}
}}
>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
"placeholder" in insertItem && !!insertItem.placeholder
? insertItem.placeholder
: insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
onKeyDown={handleLinkInputKeydown}
onPaste={handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>{renderItems()}</List>
)}
</BouncyPopoverContent>
</Popover>
{fileInput}
{submenu && itemRefs.current.get(submenu.index) && (
<Popover open modal={false}>
<PopoverAnchor
virtualRef={{
current: {
getBoundingClientRect: () =>
itemRefs.current.get(submenu.index)!.getBoundingClientRect(),
},
}}
/>
<SubmenuPopoverContent
ref={submenuContentRef}
side="right"
align="start"
sideOffset={0}
width={220}
shrink
style={{ padding: 0 }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onPointerLeave={() => setSubmenu(null)}
>
<MouseSafeArea parentRef={submenuContentRef} />
<List>
{submenu.items.map((child, childIndex) => {
if (child.name === "separator") {
return (
<ListItem key={childIndex}>
<hr />
</ListItem>
);
}
if (!child.title) {
return null;
}
const handleChildPointerMove = (ev: React.PointerEvent) => {
if (
submenu.selectedIndex !== childIndex &&
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSubmenu((s) =>
s ? { ...s, selectedIndex: childIndex } : s
);
}
onKeyDown={handleLinkInputKeydown}
onPaste={handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>{renderItems()}</List>
)}
{fileInput}
</>
)}
</Wrapper>
</Portal>
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handleChildClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(child);
setSubmenu(null);
};
return (
<ListItem
key={`sub-${childIndex}-${child.name}`}
onPointerMove={handleChildPointerMove}
>
{props.renderMenuItem(child as any, childIndex, {
selected: childIndex === submenu.selectedIndex,
onClick: handleChildClick,
})}
</ListItem>
);
})}
</List>
</SubmenuPopoverContent>
</Popover>
)}
</>
);
}
const bouncyFadeIn = keyframes`
from {
opacity: 0;
transform: scale(0.95);
}
`;
const BouncyPopoverContent = styled(PopoverContent)`
&[data-state="open"] {
animation: ${bouncyFadeIn} 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
`;
const SubmenuPopoverContent = styled(PopoverContent)`
max-height: min(324px, var(--radix-popover-content-available-height));
`;
const LinkInputWrapper = styled.div`
margin: 8px;
`;
@@ -839,6 +1095,13 @@ const List = styled.ol`
height: 100%;
padding: 6px;
margin: 0;
white-space: nowrap;
hr {
border: 0;
height: 0;
border-top: 1px solid ${s("divider")};
}
`;
const ListItem = styled.li`
@@ -860,61 +1123,4 @@ const MobileScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export const Wrapper = styled(Scrollable)<{
active: boolean;
top?: number;
bottom?: number;
left?: number;
isAbove: boolean;
}>`
color: ${s("textSecondary")};
font-family: ${s("fontFamily")};
position: absolute;
z-index: ${depths.editorToolbar};
${(props) => props.top !== undefined && `top: ${props.top}px`};
${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`};
left: ${(props) => props.left}px;
background: ${s("menuBackground")};
border-radius: 6px;
box-shadow:
rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
rgba(0, 0, 0, 0.08) 0px 4px 8px,
rgba(0, 0, 0, 0.08) 0px 2px 4px;
opacity: 0;
transform: scale(0.95);
transition:
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
width: 280px;
height: auto;
max-height: 324px;
* {
box-sizing: border-box;
}
hr {
border: 0;
height: 0;
border-top: 1px solid ${s("divider")};
}
${({ active, isAbove }) =>
active &&
`
transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1);
pointer-events: all;
opacity: 1;
`};
@media print {
display: none;
}
`;
export default SuggestionsMenu;
@@ -5,6 +5,7 @@ import styled from "styled-components";
import { usePortalContext } from "~/components/Portal";
import {
MenuButton,
MenuDisclosure,
MenuIconWrapper,
MenuLabel,
} from "~/components/primitives/components/Menu";
@@ -26,6 +27,8 @@ export type Props = {
subtitle?: React.ReactNode;
/** A string representing the keyboard shortcut for the item */
shortcut?: string;
/** Whether to show a disclosure arrow indicating a submenu */
disclosure?: boolean;
};
function SuggestionsMenuItem({
@@ -37,6 +40,7 @@ function SuggestionsMenuItem({
subtitle,
shortcut,
icon,
disclosure,
}: Props) {
const portal = usePortalContext();
const ref = React.useCallback(
@@ -75,6 +79,7 @@ function SuggestionsMenuItem({
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
{disclosure && <MenuDisclosure />}
</MenuButton>
);
}
+4 -6
View File
@@ -55,12 +55,10 @@ export default class BlockMenuExtension extends Suggestion {
Decoration.widget(
parent.pos,
() => {
button.addEventListener(
"click",
action(() => {
this.state.open = true;
})
);
button.onclick = action(() => {
this.state.query = "";
this.state.open = true;
});
return button;
},
{
@@ -12,6 +12,10 @@ export default class ClipboardTextSerializer extends Extension {
return "clipboardTextSerializer";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const mdSerializer = this.editor.extensions.serializer();
+4
View File
@@ -33,6 +33,10 @@ export default class HoverPreviews extends Extension {
return "hover-previews";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const isHoverTarget = (target: Element | null, view: EditorView) =>
target instanceof HTMLElement &&
+4
View File
@@ -25,6 +25,10 @@ export default class Multiplayer extends Extension {
return "multiplayer";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const { user, provider, document: doc } = this.options;
const type = doc.get("default", Y.XmlFragment);
+55 -14
View File
@@ -133,7 +133,10 @@ export type Props = {
/** Callback when file upload progress changes */
onFileUploadProgress?: (id: string, fractionComplete: number) => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (params: Properties<Document>) => Promise<string>;
onCreateLink?: (
params: Properties<Document>,
nested?: boolean
) => Promise<string>;
/** Callback when user clicks on any link in the document */
onClickLink: (
href: string,
@@ -250,17 +253,25 @@ export class Editor extends React.PureComponent<
this.view.updateState(newState);
}
// pass readOnly changes through to underlying editor instance
if (prevProps.readOnly !== this.props.readOnly) {
// When transitioning from readOnly to editable, reinitialize to create
// editing extensions, keymaps, input rules, and commands that were skipped.
if (prevProps.readOnly && !this.props.readOnly) {
const docJSON = this.view.state.doc.toJSON();
this.view.destroy();
this.init();
const newState = this.createState(docJSON);
this.view.updateState(newState);
} else if (!prevProps.readOnly && this.props.readOnly) {
// pass readOnly changes through to underlying editor instance
this.view.update({
...this.view.props,
editable: () => !this.props.readOnly,
editable: () => false,
});
// NodeView will not automatically render when editable changes so we must trigger an update
// manually, see: https://discuss.prosemirror.net/t/re-render-custom-nodeview-when-view-editable-changes/6441
Array.from(this.renderers).forEach((view) =>
view.setProp("isEditable", !this.props.readOnly)
view.setProp("isEditable", false)
);
}
@@ -301,15 +312,24 @@ export class Editor extends React.PureComponent<
this.nodes = this.createNodes();
this.marks = this.createMarks();
this.schema = this.createSchema();
this.widgets = this.createWidgets();
this.plugins = this.createPlugins();
this.rulePlugins = this.createRulePlugins();
this.keymaps = this.createKeymaps();
this.serializer = this.createSerializer();
this.parser = this.createParser();
this.pasteParser = this.createPasteParser();
this.inputRules = this.createInputRules();
this.nodeViews = this.createNodeViews();
this.widgets = this.createWidgets();
if (this.props.readOnly) {
this.keymaps = [];
this.inputRules = [];
this.pasteParser = this.parser;
} else {
this.keymaps = this.createKeymaps();
this.inputRules = this.createInputRules();
this.pasteParser = this.createPasteParser();
}
this.view = this.createView();
this.commands = this.createCommands();
}
@@ -411,12 +431,20 @@ export class Editor extends React.PureComponent<
private createState(value?: string | ProsemirrorData | ProsemirrorNode) {
const doc = this.createDocument(value || this.props.defaultValue);
if (this.props.readOnly) {
return EditorState.create({
schema: this.schema,
doc,
plugins: [...this.plugins, anchorPlugin()],
});
}
return EditorState.create({
schema: this.schema,
doc,
plugins: [
...this.keymaps,
...this.plugins,
...this.keymaps,
anchorPlugin(),
dropCursor({
color: this.props.theme.cursor,
@@ -620,12 +648,25 @@ export class Editor extends React.PureComponent<
window?.getSelection()?.removeAllRanges();
};
/**
* Insert content into the editor, replacing the block at the current selection.
*
* @param content The prosemirror data to insert.
*/
public insertContent = (content: ProsemirrorData) => {
const doc = ProsemirrorNode.fromJSON(this.schema, content);
const { $from } = this.view.state.selection;
const start = $from.before($from.depth);
const end = $from.after($from.depth);
this.view.dispatch(this.view.state.tr.replaceWith(start, end, doc.content));
};
/**
* Insert files at the current selection.
* =
* @param event The source event
* @param files The files to insert
* @returns True if the files were inserted
*
* @param event The source event.
* @param files The files to insert.
* @returns True if the files were inserted.
*/
public insertFiles = (
event: React.ChangeEvent<HTMLInputElement>,
+11 -1
View File
@@ -1,4 +1,4 @@
import { TrashIcon, DownloadIcon, ReplaceIcon } from "outline-icons";
import { TrashIcon, DownloadIcon, ReplaceIcon, PDFIcon } from "outline-icons";
import type { EditorState } from "prosemirror-state";
import type { MenuItem } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
@@ -17,6 +17,9 @@ export default function attachmentMenuItems(
const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, {
preview: true,
});
const isPdfAttachment = isNodeActive(schema.nodes.attachment, {
contentType: "application/pdf",
});
return [
{
@@ -29,6 +32,13 @@ export default function attachmentMenuItems(
tooltip: dictionary.deleteAttachment,
icon: <TrashIcon />,
},
{
name: "toggleAttachmentPreview",
tooltip: dictionary.previewAttachment,
icon: <PDFIcon />,
active: isAttachmentWithPreview,
visible: isPdfAttachment(state),
},
{
name: "separator",
},
+7 -6
View File
@@ -126,6 +126,7 @@ export default function blockMenuItems(
accept: "application/pdf",
width: 300,
height: 424,
preview: true,
},
},
{
@@ -164,6 +165,12 @@ export default function blockMenuItems(
icon: <MathIcon />,
keywords: "math katex latex",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
{
name: "hr",
title: dictionary.hr,
@@ -243,12 +250,6 @@ export default function blockMenuItems(
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
keywords: "diagram flowchart draw.io",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
];
// Filter out diagrams.net in desktop app
+2 -3
View File
@@ -14,6 +14,7 @@ import {
import { isMermaid } from "@shared/editor/lib/isCode";
import type { MenuItem } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
export default function codeMenuItems(
state: EditorState,
@@ -60,13 +61,11 @@ export default function codeMenuItems(
: undefined,
tooltip: dictionary.copy,
},
{
name: "separator",
},
{
name: "edit_mermaid",
icon: <EditIcon />,
tooltip: dictionary.editDiagram,
shortcut: `${metaDisplay} Enter`,
visible: isMermaid(node) && !isEditingMermaid && !readOnly,
},
{
+3
View File
@@ -32,6 +32,7 @@ export default function useDictionary() {
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
collapseCode: t("Collapse"),
comment: t("Comment"),
copy: t("Copy"),
createLink: t("Create link"),
@@ -44,6 +45,7 @@ export default function useDictionary() {
deleteRow: t("Delete"),
deleteTable: t("Delete table"),
deleteAttachment: t("Delete file"),
previewAttachment: t("Show preview"),
dimensions: `${t("Width")} × ${t("Height")}`,
download: t("Download"),
downloadAttachment: t("Download file"),
@@ -53,6 +55,7 @@ export default function useDictionary() {
replaceImage: t("Replace image"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
expandCode: t("Expand"),
file: t("File attachment"),
pdf: t("Embed PDF"),
enterLink: `${t("Enter a link")}`,
+4
View File
@@ -24,8 +24,10 @@ import {
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
downloadDocument,
copyDocument,
presentDocument,
printDocument,
searchInDocument,
deleteDocument,
@@ -106,6 +108,8 @@ export function useDocumentMenuAction({
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
presentDocument,
downloadDocument,
copyDocument,
printDocument,
+48 -22
View File
@@ -1,11 +1,11 @@
import * as React from "react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import type Group from "~/models/Group";
import {
DeleteGroupDialog,
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -16,27 +16,35 @@ import {
} from "~/actions";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import { settingsPath } from "~/utils/routeHelpers";
interface Options {
/** Whether to hide the "Members" navigation action. */
hideMembers?: boolean;
}
/**
* Hook that constructs the action menu for group management operations.
*
*
* @param targetGroup - the group to build actions for, or null to skip.
* @param options - optional configuration for the menu.
* @returns action with children for use in menus, or undefined if group is null.
*/
export function useGroupMenuActions(targetGroup: Group | null) {
export function useGroupMenuActions(
targetGroup: Group | null,
options?: Options
) {
const { t } = useTranslation();
const { dialogs } = useStores();
const history = useHistory();
const can = usePolicy(targetGroup ?? ({} as Group));
const openMembersDialog = React.useCallback(() => {
const navigateToMembers = React.useCallback(() => {
if (!targetGroup) {
return;
}
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={targetGroup} />,
});
}, [t, targetGroup, dialogs]);
history.push(settingsPath("groups", targetGroup.id, "members"));
}, [targetGroup, history]);
const openEditDialog = React.useCallback(() => {
if (!targetGroup) {
@@ -45,7 +53,10 @@ export function useGroupMenuActions(targetGroup: Group | null) {
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
<EditGroupDialog
group={targetGroup}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, targetGroup, dialogs]);
@@ -57,7 +68,10 @@ export function useGroupMenuActions(targetGroup: Group | null) {
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
<DeleteGroupDialog
group={targetGroup}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, targetGroup, dialogs]);
@@ -67,26 +81,30 @@ export function useGroupMenuActions(targetGroup: Group | null) {
!targetGroup
? []
: [
createAction({
name: `${t("Members")}`,
icon: <GroupIcon />,
section: GroupSection,
visible: !!(targetGroup && can.read),
perform: openMembersDialog,
}),
ActionSeparator,
...(options?.hideMembers
? []
: [
createAction({
name: t("Members"),
icon: <GroupIcon />,
section: GroupSection,
visible: can.read,
perform: navigateToMembers,
}),
ActionSeparator,
]),
createAction({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: GroupSection,
visible: !!(targetGroup && can.update),
visible: can.update,
perform: openEditDialog,
}),
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: GroupSection,
visible: !!(targetGroup && can.delete),
visible: can.delete,
dangerous: true,
perform: openDeleteDialog,
}),
@@ -98,6 +116,13 @@ export function useGroupMenuActions(targetGroup: Group | null) {
disabled: true,
url: "",
}),
createExternalLinkAction({
name: `External ID: ${targetGroup.externalGroup?.externalId ?? ""}`,
section: GroupSection,
visible: !!targetGroup.externalGroup?.externalId,
disabled: true,
url: "",
}),
],
[
t,
@@ -105,7 +130,8 @@ export function useGroupMenuActions(targetGroup: Group | null) {
can.read,
can.update,
can.delete,
openMembersDialog,
options?.hideMembers,
navigateToMembers,
openEditDialog,
openDeleteDialog,
]
+7 -2
View File
@@ -63,9 +63,14 @@ window.addEventListener("keydown", (event) => {
return;
}
// Track whether defaultPrevented was already set by an external handler (e.g.
// Radix UI's DismissableLayer) so we only break on preventDefault calls made
// by our own callbacks.
const wasDefaultPrevented = event.defaultPrevented;
// reverse so that the last registered callbacks get executed first
for (const registered of callbacks.reverse()) {
if (event.defaultPrevented === true) {
for (const registered of [...callbacks].reverse()) {
if (!wasDefaultPrevented && event.defaultPrevented) {
break;
}
+1
View File
@@ -39,6 +39,7 @@ export const useLocaleTime = ({
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
de_DE: "d. MMMM yyyy 'um' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
+2
View File
@@ -11,6 +11,7 @@ import { Router } from "react-router-dom";
import stores from "~/stores";
import Analytics from "~/components/Analytics";
import Dialogs from "~/components/Dialogs";
import Presentation from "~/components/Presentation";
import ErrorBoundary from "~/components/ErrorBoundary";
import PageTheme from "~/components/PageTheme";
import ScrollToTop from "~/components/ScrollToTop";
@@ -72,6 +73,7 @@ if (element) {
</ScrollToTop>
<Toasts />
<Dialogs />
<Presentation />
<Desktop />
</PageScroll>
</LazyMotion>
+4 -2
View File
@@ -8,11 +8,13 @@ import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
type Props = {
group: Group;
/** Whether to hide the "Members" navigation action. */
hideMembers?: boolean;
};
function GroupMenu({ group }: Props) {
function GroupMenu({ group, hideMembers }: Props) {
const { t } = useTranslation();
const rootAction = useGroupMenuActions(group);
const rootAction = useGroupMenuActions(group, { hideMembers });
return (
<DropdownMenu
+9
View File
@@ -1,4 +1,5 @@
import { computed, observable } from "mobx";
import type { AuthenticationProviderSettings } from "@shared/types";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterDelete } from "./decorators/Lifecycle";
@@ -13,6 +14,10 @@ class AuthenticationProvider extends Model {
providerId: string;
groupSyncSupported: boolean;
groupSyncUsesClaim: boolean;
@observable
isConnected: boolean;
@@ -20,6 +25,10 @@ class AuthenticationProvider extends Model {
@observable
isEnabled: boolean;
@Field
@observable
settings: AuthenticationProviderSettings | undefined;
@computed
get isActive() {
return this.isEnabled && this.isConnected;
+5
View File
@@ -67,6 +67,11 @@ export default class Collection extends ParanoidModel {
direction: "asc" | "desc";
};
/** The minimum permission level required to manage templates in this collection. */
@Field
@observable
templateManagement: CollectionPermission;
/**
* Whether commenting is enabled for the collection.
*/
+27
View File
@@ -5,6 +5,22 @@ import Field from "./decorators/Field";
import { GroupPermission } from "@shared/types";
import type { Searchable } from "./interfaces/Searchable";
/**
* Information about a group that is managed by an external provider.
*/
interface ExternalGroupInfo {
/** The unique identifier of the external group record in Outline. */
id: string;
/** The unique identifier of the group in the external provider. */
externalId: string;
/** The name of the external provider (e.g. google, slack, azure). */
provider: string;
/** The display name of the group in the external provider. */
displayName: string;
/** The date and time the group was last synced from the external provider. */
lastSyncedAt: string | null;
}
class Group extends Model implements Searchable {
static modelName = "Group";
@@ -26,6 +42,17 @@ class Group extends Model implements Searchable {
@observable
disableMentions: boolean;
@observable
externalGroup: ExternalGroupInfo | undefined;
/**
* Whether this group's membership is managed by an external authentication provider.
*/
@computed
get isExternallyManaged(): boolean {
return !!this.externalGroup;
}
/**
* Returns the users that are members of this group.
*/
+12 -4
View File
@@ -1,12 +1,15 @@
import { Switch } from "react-router-dom";
import Error404 from "~/scenes/Errors/Error404";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import { observer } from "mobx-react";
const Application = lazy(() => import("~/scenes/Settings/Application"));
const GroupMembers = lazy(() => import("~/scenes/Settings/GroupMembers"), {
exportName: "GroupMembersScene",
});
const Template = lazy(() => import("~/scenes/Settings/Template"));
const TemplateNew = lazy(() => import("~/scenes/Settings/TemplateNew"));
@@ -24,20 +27,25 @@ function SettingsRoutes() {
/>
))}
{/* TODO: Refactor these exceptions into config? */}
<Route
exact
path={settingsPath("groups", ":id", "members")}
component={GroupMembers.Component}
/>
<Route
exact
path={settingsPath("applications", ":id")}
component={Application}
component={Application.Component}
/>
<Route
exact
path={settingsPath("templates", "new")}
component={TemplateNew}
component={TemplateNew.Component}
/>
<Route
exact
path={settingsPath("templates", ":id")}
component={Template}
component={Template.Component}
/>
<Route component={Error404} />
</Switch>
+9 -2
View File
@@ -66,7 +66,12 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
shortcut="e"
placement="bottom"
>
<Button icon={<EditIcon />} onClick={goToEdit} neutral>
<Button
icon={<EditIcon />}
onClick={goToEdit}
haptic="light"
neutral
>
{t("Edit")}
</Button>
</Tooltip>
@@ -75,7 +80,9 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
{isEditing && user?.separateEditMode && (
<Action>
<RegisterKeyDown trigger="Escape" handler={goBack} />
<Button onClick={goBack}>{t("Done editing")}</Button>
<Button onClick={goBack} haptic="medium">
{t("Done editing")}
</Button>
</Action>
)}
{can.createDocument && (
@@ -61,7 +61,7 @@ function Overview({ collection, readOnly }: Props) {
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
);
@@ -12,6 +12,7 @@ import {
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import lazyWithRetry from "~/utils/lazyWithRetry";
const SharePopover = lazyWithRetry(
@@ -64,6 +65,7 @@ function ShareButton({ collection }: Props) {
minHeight={175}
side="bottom"
align="end"
onEscapeKeyDown={preventDefault}
>
<Suspense fallback={null}>
<SharePopover
@@ -31,7 +31,8 @@ import useMobile from "~/hooks/useMobile";
function Comments() {
const { ui, comments, documents } = useStores();
const user = useCurrentUser();
const { editor, isEditorInitialized } = useDocumentContext();
const { editor, isEditorInitialized, setFocusedCommentId } =
useDocumentContext();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const document = documents.get(match.params.documentSlug);
@@ -203,7 +204,10 @@ function Comments() {
/>
</Flex>
}
onClose={() => ui.set({ rightSidebar: null })}
onClose={() => {
ui.set({ rightSidebar: null });
setFocusedCommentId(null);
}}
scrollable={false}
>
{content}
@@ -16,6 +16,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import type { Properties } from "~/types";
import Logger from "~/utils/Logger";
@@ -88,6 +89,7 @@ function DataLoader({ match, children }: Props) {
const isEditing = isEditRoute || !user?.separateEditMode;
const can = usePolicy(document);
const location = useLocation<LocationState>();
const query = useQuery();
const missingPolicy = !can || Object.keys(can).length === 0;
useDocumentSidebar();
@@ -205,6 +207,13 @@ function DataLoader({ match, children }: Props) {
revisionId,
]);
// Auto-enter presentation mode when ?present=true query param is set
React.useEffect(() => {
if (document && query.has("present") && !ui.presentationData) {
ui.setPresentingDocument(document);
}
}, [document, query, ui]);
if (error) {
return error instanceof OfflineError ? (
<ErrorOffline />
+9 -7
View File
@@ -669,9 +669,11 @@ const Main = styled.div<MainProps>`
@media print {
display: block;
max-width: calc(
${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter}
);
max-width: ${({ fullWidth }: MainProps) =>
fullWidth
? `100%`
: `calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`
};
}
`;
@@ -720,10 +722,10 @@ const EditorContainer = styled.div<EditorContainerProps>`
// Decides the editor column position & span
grid-column: ${({
docFullWidth,
showContents,
tocPosition,
}: EditorContainerProps) =>
docFullWidth,
showContents,
tocPosition,
}: EditorContainerProps) =>
docFullWidth
? showContents
? tocPosition === TOCPosition.Left
+1 -1
View File
@@ -172,7 +172,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
);
@@ -158,6 +158,7 @@ function DocumentHeader({
pathname: documentEditPath(document),
state: { sidebarContext },
}}
haptic="light"
neutral
>
{isMobile ? null : t("Edit")}
@@ -283,6 +284,7 @@ function DocumentHeader({
onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
haptic="medium"
hideIcon
>
{isDraft ? t("Save draft") : t("Done editing")}
@@ -0,0 +1,501 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { ShrinkIcon, GrowIcon, CloseIcon } from "outline-icons";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import { richExtensions } from "@shared/editor/nodes";
import { canUseElementFullscreen } from "@shared/utils/browser";
import { s, depths, hover } from "@shared/styles";
import type { ProsemirrorData } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Editor from "~/components/Editor";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import Flex from "~/components/Flex";
import Tooltip from "~/components/Tooltip";
import useIdle from "~/hooks/useIdle";
import useKeyDown from "~/hooks/useKeyDown";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
/** Activity events that reset the idle timer — excludes keyboard to stay idle during navigation. */
const idleEvents = [
"click",
"mousemove",
"mousedown",
"touchstart",
"touchmove",
];
type Slide =
| {
type: "title";
title: string;
icon?: string | null;
iconColor?: string | null;
}
| { type: "content"; content: ProsemirrorData[] }
| { type: "instructions" };
interface Props {
/** The document title. */
title: string;
/** The document icon. */
icon?: string | null;
/** The document icon color. */
iconColor?: string | null;
/** The prosemirror data for the document. */
data: ProsemirrorData;
/** Callback when presentation mode is closed. */
onClose: () => void;
}
/**
* Returns true if the given content nodes contain no meaningful text or elements.
*
* @param nodes the prosemirror content nodes.
* @returns true when every node is an empty paragraph.
*/
function isContentEmpty(nodes: ProsemirrorData[]): boolean {
return nodes.every(
(node) =>
node.type === "paragraph" && (!node.content || node.content.length === 0)
);
}
/**
* Splits a ProseMirror document into slides based on heading and divider nodes.
* A dedicated title slide is prepended. Each h1/h2 heading or horizontal rule
* starts a new content slide. Divider nodes are consumed as separators and not
* rendered on slides.
*
* @param data the prosemirror document data.
* @param title the document title.
* @param icon the document icon.
* @param iconColor the document icon color.
* @returns an array of slides.
*/
function splitIntoSlides(
data: ProsemirrorData,
title: string,
icon?: string | null,
iconColor?: string | null
): Slide[] {
const content = data.content ?? [];
const slides: Slide[] = [{ type: "title", title, icon, iconColor }];
let currentNodes: ProsemirrorData[] = [];
for (const node of content) {
const isDivider = node.type === "horizontal_rule" || node.type === "hr";
const isHeadingBreak =
node.type === "heading" &&
node.attrs &&
typeof node.attrs.level === "number" &&
node.attrs.level <= 2;
if (isDivider) {
if (currentNodes.length > 0) {
slides.push({ type: "content", content: currentNodes });
currentNodes = [];
}
continue;
}
if (isHeadingBreak && currentNodes.length > 0) {
slides.push({ type: "content", content: currentNodes });
currentNodes = [];
}
currentNodes.push(node);
}
if (currentNodes.length > 0) {
slides.push({ type: "content", content: currentNodes });
}
return slides;
}
/**
* Full-screen presentation mode that splits a document into slides by headings
* and dividers, and allows navigating through them with keyboard controls.
*/
function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const [currentSlide, setCurrentSlide] = React.useState(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const slideContentRef = React.useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const supportsFullscreen = React.useMemo(() => canUseElementFullscreen(), []);
const isIdle = useIdle(3000, idleEvents);
const slides = React.useMemo(() => {
const result = splitIntoSlides(data, title, icon, iconColor);
const contentSlides = result.filter((s) => s.type === "content");
const hasContent =
contentSlides.length > 0 &&
contentSlides.some(
(s) => s.type === "content" && !isContentEmpty(s.content)
);
if (!hasContent) {
return [result[0], { type: "instructions" as const }];
}
return result;
}, [data, title, icon, iconColor]);
const totalSlides = slides.length;
const goNext = React.useCallback(() => {
setCurrentSlide((prev) => Math.min(prev + 1, totalSlides - 1));
}, [totalSlides]);
const goPrev = React.useCallback(() => {
setCurrentSlide((prev) => Math.max(prev - 1, 0));
}, []);
const goFirst = React.useCallback(() => {
setCurrentSlide(0);
}, []);
const goLast = React.useCallback(() => {
setCurrentSlide(totalSlides - 1);
}, [totalSlides]);
const toggleFullscreen = React.useCallback(() => {
if (!supportsFullscreen) {
return;
}
const el = containerRef.current;
if (!el) {
return;
}
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {
// ignore
});
} else {
el.requestFullscreen().catch(() => {
// ignore
});
}
}, [supportsFullscreen]);
useKeyDown("Escape", onClose);
useKeyDown("ArrowRight", goNext);
useKeyDown("ArrowDown", goNext);
useKeyDown("PageDown", goNext);
useKeyDown("ArrowLeft", goPrev);
useKeyDown("ArrowUp", goPrev);
useKeyDown("PageUp", goPrev);
useKeyDown("Home", goFirst);
useKeyDown("End", goLast);
useKeyDown(" ", goNext);
useKeyDown("f", toggleFullscreen);
// Prevent body scrolling while presentation is open
React.useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
// Track fullscreen state changes
React.useEffect(() => {
if (!supportsFullscreen) {
return;
}
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {
// ignore
});
}
};
}, [supportsFullscreen]);
// Measure natural size once per slide, then apply scale directly to the DOM
// to avoid React re-render loops during window resize.
const naturalSize = React.useRef({ width: 0, height: 0 });
React.useEffect(() => {
const el = slideContentRef.current;
const container = containerRef.current;
if (!el || !container) {
return;
}
const applyScale = () => {
const { width, height } = naturalSize.current;
if (width === 0 || height === 0) {
el.style.transform = "scale(1)";
return;
}
const availableWidth = container.clientWidth - 160;
const availableHeight = container.clientHeight - 48 - 160;
const scaleX = availableWidth / width;
const scaleY = availableHeight / height;
const newScale = Math.min(scaleX, scaleY, 1.5);
el.style.transform = `scale(${Math.max(newScale, 0.5)})`;
};
// Measure natural size with scale removed, then apply
el.style.transform = "none";
requestAnimationFrame(() => {
naturalSize.current = {
width: el.scrollWidth,
height: el.scrollHeight,
};
applyScale();
window.addEventListener("resize", applyScale);
});
return () => {
window.removeEventListener("resize", applyScale);
};
}, [currentSlide]);
const slide = slides[currentSlide];
const slideData: ProsemirrorData | undefined = React.useMemo(
() =>
slide.type === "content"
? { type: "doc", content: slide.content }
: undefined,
[slide]
);
const extensions = React.useMemo(() => richExtensions, []);
return createPortal(
<Container ref={containerRef} $background={theme.background} $idle={isIdle}>
<TopBar $idle={isIdle}>
<Flex align="center" gap={12}>
<Tooltip content={t("Previous slide")} delay={500}>
<Button onClick={goPrev} disabled={currentSlide === 0}>
<ArrowLeftIcon />
</Button>
</Tooltip>
<SlideCounter>
{currentSlide + 1} / {totalSlides}
</SlideCounter>
<Tooltip content={t("Next slide")} delay={500}>
<Button
onClick={goNext}
disabled={currentSlide === totalSlides - 1}
>
<ArrowRightIcon color="currentColor" />
</Button>
</Tooltip>
</Flex>
<RightButtons>
{supportsFullscreen && (
<Tooltip content={t("Toggle fullscreen")} delay={500}>
<Button onClick={toggleFullscreen}>
{isFullscreen ? (
<ShrinkIcon color="currentColor" />
) : (
<GrowIcon color="currentColor" />
)}
</Button>
</Tooltip>
)}
<Tooltip content={t("Close")} delay={500}>
<Button onClick={onClose}>
<CloseIcon />
</Button>
</Tooltip>
</RightButtons>
</TopBar>
<SlideArea onClick={goNext}>
<SlideContent ref={slideContentRef}>
{slide.type === "title" ? (
<TitleSlide>
{slide.icon && (
<TitleIcon>
<Icon
value={slide.icon}
color={slide.iconColor ?? colorPalette[0]}
size={64}
initial={slide.title[0]}
/>
</TitleIcon>
)}
<TitleText>{slide.title}</TitleText>
</TitleSlide>
) : slide.type === "instructions" ? (
<InstructionSlide>
<InstructionHeading>
{t("Create your presentation")}
</InstructionHeading>
<InstructionBody>
{t(
"Add content to your document, then use headings or dividers to separate it into slides."
)}{" "}
<a
href="https://docs.getoutline.com/s/guide/doc/present-mode-yMGzaY7A9L"
target="_blank"
>
{t("Learn more")}
</a>
.
</InstructionBody>
</InstructionSlide>
) : slideData ? (
<Editor
key={currentSlide}
defaultValue={slideData}
extensions={extensions}
readOnly
grow={false}
placeholder=""
/>
) : null}
</SlideContent>
</SlideArea>
</Container>,
document.body
);
}
const Container = styled.div<{ $background: string; $idle: boolean }>`
position: fixed;
inset: 0;
z-index: ${depths.presentation};
background: ${(props) => props.$background};
display: flex;
flex-direction: column;
user-select: none;
cursor: ${(props) => (props.$idle ? "none" : "default")};
* {
cursor: inherit;
}
`;
const SlideArea = styled.div`
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 80px;
`;
const SlideContent = styled.div`
max-width: 960px;
width: 100%;
transform-origin: center center;
.ProseMirror {
padding: 0;
font-size: 1.4em;
}
h1 {
font-size: 2.4em;
}
h2 {
font-size: 1.8em;
}
h3 {
font-size: 1.4em;
}
`;
const TitleSlide = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 24px;
min-height: 200px;
`;
const TitleIcon = styled.div`
flex-shrink: 0;
`;
const TitleText = styled.h1`
font-size: 3em;
font-weight: 600;
line-height: 1.25;
margin: 0;
color: ${s("text")};
`;
const TopBar = styled.div<{ $idle: boolean }>`
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
position: relative;
opacity: ${(props) => (props.$idle ? 0 : 1)};
transition: opacity 300ms ease;
`;
const SlideCounter = styled(Text)`
font-variant-numeric: tabular-nums;
color: ${s("textTertiary")};
font-size: 14px;
min-width: 60px;
text-align: center;
`;
const RightButtons = styled(Flex).attrs({ align: "center", gap: 16 })`
position: absolute;
right: 16px;
`;
const Button = styled(NudeButton).attrs({ size: 32 })`
&:not(:disabled) {
color: ${s("textTertiary")};
&:${hover},
&:active {
color: ${s("text")};
}
}
&:disabled {
color: ${s("textTertiary")};
opacity: 0.5;
}
`;
const InstructionSlide = styled(TitleSlide)`
gap: 16px;
max-width: 560px;
margin: 0 auto;
`;
const InstructionHeading = styled.h2`
font-size: 2em;
font-weight: 600;
margin: 0;
color: ${s("text")};
`;
const InstructionBody = styled.p`
font-size: 1.2em;
line-height: 1.6;
margin: 0;
color: ${s("textSecondary")};
`;
export default PresentationMode;
@@ -30,7 +30,7 @@ function References({ document }: Props) {
useEffect(() => {
if (!isShare) {
void documents.fetchBacklinks(document.id);
void documents.fetchRelationships(document.id);
}
}, [isShare, documents, document.id]);
@@ -11,6 +11,7 @@ import {
} from "~/components/primitives/Popover";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import lazyWithRetry from "~/utils/lazyWithRetry";
const SharePopover = lazyWithRetry(
@@ -58,6 +59,7 @@ function ShareButton({ document }: Props) {
minHeight={175}
side="bottom"
align="end"
onEscapeKeyDown={preventDefault}
>
<Suspense fallback={null}>
<SharePopover
+9
View File
@@ -108,6 +108,15 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
),
label: t("Go to link"),
},
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key symbol>{altDisplay}</Key>{" "}
+ <Key>p</Key>
</>
),
label: t("Present document"),
},
{
shortcut: (
<>
+2 -5
View File
@@ -28,6 +28,7 @@ import usePaginatedRequest from "~/hooks/usePaginatedRequest";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import type { PaginationParams, SearchResult } from "~/types";
import { preventDefault } from "~/utils/events";
import { searchPath } from "~/utils/routeHelpers";
import { decodeURIComponentSafe } from "~/utils/urls";
import CollectionFilter from "./components/CollectionFilter";
@@ -251,11 +252,7 @@ function Search() {
<RegisterKeyDown trigger="Escape" handler={history.goBack} />
{loading && <LoadingIndicator />}
<ResultsWrapper column auto>
<form
method="GET"
action={searchPath()}
onSubmit={(ev) => ev.preventDefault()}
>
<form method="GET" action={searchPath()} onSubmit={preventDefault}>
<SearchInput
name="query"
key={query ? "search" : "recent"}
+229 -46
View File
@@ -6,6 +6,8 @@ import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import { InputSelect } from "~/components/InputSelect";
import type AuthenticationProvider from "~/models/AuthenticationProvider";
import PluginIcon from "~/components/PluginIcon";
import Scene from "~/components/Scene";
@@ -21,6 +23,7 @@ import { settingsPath } from "~/utils/routeHelpers";
import DomainManagement from "./components/DomainManagement";
import Button from "~/components/Button";
import { ConnectedIcon } from "~/components/Icons/ConnectedIcon";
import { client } from "~/utils/ApiClient";
import { useTheme } from "styled-components";
import { VStack } from "~/components/primitives/VStack";
@@ -97,6 +100,54 @@ function Authentication() {
window.location.href = `/auth/${name}?host=${window.location.host}`;
}, []);
const handleToggleGroupSync = React.useCallback(
(provider: AuthenticationProvider, checked: boolean) => {
if (checked) {
void (async () => {
try {
await provider.save({
settings: {
...provider.settings,
groupSyncEnabled: true,
},
});
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
})();
} else {
dialogs.openModal({
title: t("Disable group sync"),
content: (
<DisableGroupSyncDialog
provider={provider}
onSubmit={dialogs.closeAllModals}
/>
),
});
}
},
[t, dialogs]
);
const handleGroupClaimChange = React.useCallback(
async (provider: AuthenticationProvider, groupClaim: string) => {
try {
await provider.save({
settings: {
...provider.settings,
groupClaim,
},
});
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
},
[t]
);
const showSuccessMessage = React.useMemo(
() => () => toast.success(t("Settings saved")),
[t]
@@ -115,58 +166,107 @@ function Authentication() {
<Heading as="h2">{t("Sign In")}</Heading>
{authenticationProviders.orderedData.map((provider) => (
<SettingRow
key={provider.name}
label={
<Flex gap={8} align="center">
<PluginIcon id={provider.name} /> {provider.displayName}
</Flex>
}
name={provider.name}
description={
provider.isConnected
? t("Allow members to sign-in with {{ authProvider }}", {
authProvider: provider.displayName,
})
: t("Connect {{ authProvider }} to allow members to sign-in", {
authProvider: provider.displayName,
})
}
>
<Flex align="center" gap={12}>
{provider.isConnected ? (
<VStack align="start">
<React.Fragment key={provider.name}>
<SettingRow
label={
<Flex gap={8} align="center">
<PluginIcon id={provider.name} /> {provider.displayName}
</Flex>
}
name={provider.name}
description={
provider.isConnected
? t("Allow members to sign-in with {{ authProvider }}", {
authProvider: provider.displayName,
})
: t("Connect {{ authProvider }} to allow members to sign-in", {
authProvider: provider.displayName,
})
}
border={!(provider.isActive && provider.groupSyncSupported)}
>
<Flex align="center" gap={12}>
{provider.isConnected ? (
<VStack align="start">
<Button
icon={
provider.isEnabled ? (
<ConnectedIcon />
) : (
<ConnectedIcon color={theme.textSecondary} />
)
}
onClick={() =>
!provider.isEnabled
? handleToggleProvider(provider, true)
: handleRemoveProvider(provider)
}
neutral
>
{provider.isEnabled ? t("Connected") : t("Disabled")}
</Button>
<Text type="tertiary" size="small">
{provider.providerId}
</Text>
</VStack>
) : (
<Button
icon={
provider.isEnabled ? (
<ConnectedIcon />
) : (
<ConnectedIcon color={theme.textSecondary} />
)
}
onClick={() =>
!provider.isEnabled
? handleToggleProvider(provider, true)
: handleRemoveProvider(provider)
}
onClick={() => handleConnectProvider(provider.name)}
neutral
>
{provider.isEnabled ? t("Connected") : t("Disabled")}
{t("Connect")}
</Button>
<Text type="tertiary" size="small">
{provider.providerId}
</Text>
</VStack>
) : (
<Button
onClick={() => handleConnectProvider(provider.name)}
neutral
)}
</Flex>
</SettingRow>
{provider.isActive && provider.groupSyncSupported && (
<SettingRow
label={t("Group sync")}
name={`groupSync-${provider.name}`}
description={t(
"Sync group memberships from {{ authProvider }} on each sign-in",
{ authProvider: provider.displayName }
)}
border={
!(
provider.settings?.groupSyncEnabled &&
provider.groupSyncUsesClaim
)
}
>
<Switch
id={`groupSync-${provider.name}`}
checked={provider.settings?.groupSyncEnabled ?? false}
onChange={(checked) => handleToggleGroupSync(provider, checked)}
/>
</SettingRow>
)}
{provider.isActive &&
provider.groupSyncSupported &&
provider.groupSyncUsesClaim &&
provider.settings?.groupSyncEnabled && (
<SettingRow
label={t("Group claim")}
name={`groupClaim-${provider.name}`}
description={t(
"The claim in the provider response that contains group names (e.g. groups, roles)"
)}
border={false}
>
{t("Connect")}
</Button>
<Input
id={`groupClaim-${provider.name}`}
defaultValue={provider.settings?.groupClaim ?? "groups"}
placeholder="groups"
onBlur={(ev: React.FocusEvent<HTMLInputElement>) => {
const value = ev.target.value.trim();
if (value !== (provider.settings?.groupClaim ?? "")) {
void handleGroupClaimChange(provider, value);
}
}}
/>
</SettingRow>
)}
</Flex>
</SettingRow>
</React.Fragment>
))}
<SettingRow
label={
@@ -219,4 +319,87 @@ function Authentication() {
);
}
const DisableGroupSyncDialog = observer(function DisableGroupSyncDialog({
provider,
onSubmit,
}: {
provider: AuthenticationProvider;
onSubmit: () => void;
}) {
const { t } = useTranslation();
const [action, setAction] = React.useState("keep");
const [isSaving, setIsSaving] = React.useState(false);
const options = React.useMemo(
() => [
{
type: "item" as const,
label: t("Keep synced groups"),
description: t("Groups will remain but no longer update"),
value: "keep",
},
{
type: "item" as const,
label: t("Delete synced groups"),
description: t("Remove all groups created by sync"),
value: "delete",
},
],
[t]
);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await provider.save({
settings: {
...provider.settings,
groupSyncEnabled: false,
},
});
if (action === "delete") {
await client.post("/groups.deleteAll", {
authenticationProviderId: provider.id,
});
}
toast.success(t("Settings saved"));
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[provider, action, onSubmit, t]
);
return (
<form onSubmit={handleSubmit}>
<Flex gap={12} column>
<Text type="secondary">
{t(
"Group memberships will no longer be synced from {{ authProvider }} when members sign in.",
{ authProvider: provider.displayName }
)}
</Text>
<InputSelect
label={t("Existing groups")}
options={options}
value={action}
onChange={setAction}
/>
<Flex justify="flex-end">
<Button type="submit" disabled={isSaving} danger>
{isSaving ? `${t("Disabling")}` : t("Disable")}
</Button>
</Flex>
</Flex>
</form>
);
});
export default observer(Authentication);
+284
View File
@@ -0,0 +1,284 @@
import type { ColumnSort } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { GroupIcon, HiddenIcon, PlusIcon } from "outline-icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { useHistory, useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { toast } from "sonner";
import type User from "~/models/User";
import { Action } from "~/components/Actions";
import Breadcrumb from "~/components/Breadcrumb";
import Button from "~/components/Button";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import LoadingIndicator from "~/components/LoadingIndicator";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import Error404 from "~/scenes/Errors/Error404";
import { createInternalLinkAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import type { FetchPageParams, PaginatedResponse } from "~/stores/base/Store";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import GroupMenu from "~/menus/GroupMenu";
import { AddPeopleToGroupDialog } from "./components/GroupDialogs";
import GroupPermissionFilter from "./components/GroupPermissionFilter";
import { GroupMembersTable } from "./components/GroupMembersTable";
import { StickyFilters } from "./components/StickyFilters";
import { settingsPath } from "~/utils/routeHelpers";
/**
* Settings page that lists members of a specific group.
*/
function GroupMembers() {
const { id } = useParams<{ id: string }>();
const { groups } = useStores();
const group = groups.get(id);
const { request, error } = useRequest(() => groups.fetch(id));
useEffect(() => {
if (!group) {
void request();
}
}, [group, request]);
if (error) {
return <Error404 />;
}
if (!group) {
return <LoadingIndicator />;
}
return <GroupMembersPage groupId={group.id} />;
}
const GroupMembersPage = observer(function GroupMembersPage({
groupId,
}: {
groupId: string;
}) {
const { t } = useTranslation();
const theme = useTheme();
const { dialogs, groups, users, groupUsers } = useStores();
const group = groups.get(groupId)!;
const can = usePolicy(group);
const history = useHistory();
const location = useLocation();
const params = useQuery();
const [query, setQuery] = useState("");
const reqParams = useMemo(
() => ({
id: group.id,
query: params.get("query") || undefined,
permission: params.get("permission") || undefined,
sort: params.get("sort") || "name",
direction: (params.get("direction") || "asc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params, group.id]
);
const sort: ColumnSort = useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const fetchMembers = useCallback(
async (fetchParams: FetchPageParams): Promise<PaginatedResponse<User>> => {
const response = await groupUsers.fetchPage(fetchParams);
const result = response.map((gu) => gu.user) as PaginatedResponse<User>;
result[PAGINATION_SYMBOL] = response[PAGINATION_SYMBOL];
return result;
},
[groupUsers]
);
const filteredUsers = useMemo(() => {
let result = users.inGroup(group.id, reqParams.query);
if (reqParams.permission) {
const memberIds = new Set(
groupUsers.orderedData
.filter(
(gu) =>
gu.groupId === group.id && gu.permission === reqParams.permission
)
.map((gu) => gu.userId)
);
result = result.filter((user) => memberIds.has(user.id));
}
return result;
}, [
users,
groupUsers.orderedData,
group.id,
reqParams.query,
reqParams.permission,
]);
const { data, error, loading, next } = useTableRequest({
data: filteredUsers,
sort,
reqFn: fetchMembers,
reqParams,
});
const updateParams = useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const updateQuery = useCallback(
(value: string) => updateParams("query", value),
[updateParams]
);
const handlePermissionFilter = useCallback(
(permission: string | null | undefined) =>
updateParams("permission", permission ?? ""),
[updateParams]
);
const handleSearch = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setQuery(value);
},
[]
);
const handleAddPeople = useCallback(() => {
dialogs.openModal({
title: t(`Add people to {{groupName}}`, {
groupName: group.name,
}),
content: <AddPeopleToGroupDialog group={group} />,
});
}, [t, group, dialogs]);
useEffect(() => {
if (error) {
toast.error(t("Could not load group members"));
}
}, [t, error]);
useEffect(() => {
const timeout = setTimeout(() => updateQuery(query), 250);
return () => clearTimeout(timeout);
}, [query, updateQuery]);
const breadcrumbActions = useMemo(
() => [
createInternalLinkAction({
name: t("Groups"),
section: NavigationSection,
icon: <GroupIcon />,
to: settingsPath("groups"),
}),
],
[t]
);
return (
<Scene
title={group.name}
left={<Breadcrumb actions={breadcrumbActions} />}
actions={
<>
{can.update && (
<Action>
<Button
type="button"
onClick={handleAddPeople}
disabled={group.isExternallyManaged}
icon={<PlusIcon />}
>
{`${t("Add people")}`}
</Button>
</Action>
)}
<Action>
<GroupMenu group={group} hideMembers />
</Action>
</>
}
wide
>
<Heading>
{group.name}
{group.disableMentions && (
<>
&nbsp;
<Tooltip content={t("This group is hidden")}>
<HiddenIcon size={32} color={theme.textSecondary} />
</Tooltip>
</>
)}
</Heading>
<Text as="p" type="secondary">
{group.externalGroup && (
<>
{t("Synced to {{ provider }}", {
provider: group.externalGroup.displayName,
})}
{group.description && <> &middot; </>}
</>
)}
{group.description || (!group.externalGroup && t("No description"))}
</Text>
<StickyFilters>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeGroupPermissionFilter
activeKey={reqParams.permission ?? ""}
onSelect={handlePermissionFilter}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<GroupMembersTable
group={group}
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
</Scene>
);
});
const LargeGroupPermissionFilter = styled(GroupPermissionFilter)`
height: 32px;
`;
export const GroupMembersScene = observer(GroupMembers);
+61 -23
View File
@@ -2,6 +2,7 @@ import type { ColumnSort } from "@tanstack/react-table";
import deburr from "lodash/deburr";
import { observer } from "mobx-react";
import { PlusIcon, GroupIcon } from "outline-icons";
import * as React from "react";
import { useState, useMemo, useCallback, useEffect } from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
@@ -12,6 +13,7 @@ import Button from "~/components/Button";
import Empty from "~/components/Empty";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import styled from "styled-components";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
@@ -21,18 +23,30 @@ import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { CreateGroupDialog } from "./components/GroupDialogs";
import GroupSourceFilter from "./components/GroupSourceFilter";
import { GroupsTable } from "./components/GroupsTable";
import { StickyFilters } from "./components/StickyFilters";
import { HStack } from "~/components/primitives/HStack";
function getFilteredGroups(groups: Group[], query?: string) {
if (!query?.length) {
return groups;
function getFilteredGroups(groups: Group[], query?: string, source?: string) {
let filtered = groups;
if (query?.length) {
const normalizedQuery = deburr(query.toLocaleLowerCase());
filtered = filtered.filter((group) =>
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
);
}
const normalizedQuery = deburr(query.toLocaleLowerCase());
return groups.filter((group) =>
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
);
if (source === "manual") {
filtered = filtered.filter((group) => !group.externalGroup);
} else if (source) {
filtered = filtered.filter(
(group) => group.externalGroup?.provider === source
);
}
return filtered;
}
function Groups() {
@@ -48,6 +62,7 @@ function Groups() {
const reqParams = useMemo(
() => ({
query: params.get("query") || undefined,
source: params.get("source") || undefined,
sort: params.get("sort") || "name",
direction: (params.get("direction") || "asc").toUpperCase() as
| "ASC"
@@ -65,7 +80,11 @@ function Groups() {
);
const { data, error, loading, next } = useTableRequest({
data: getFilteredGroups(groups.orderedData, reqParams.query),
data: getFilteredGroups(
groups.orderedData,
reqParams.query,
reqParams.source
),
sort,
reqFn: groups.fetchPage,
reqParams,
@@ -73,12 +92,12 @@ function Groups() {
const isEmpty = !loading && !groups.orderedData.length;
const updateQuery = useCallback(
(value: string) => {
const updateParams = useCallback(
(name: string, value: string) => {
if (value) {
params.set("query", value);
params.set(name, value);
} else {
params.delete("query");
params.delete(name);
}
history.replace({
@@ -89,10 +108,18 @@ function Groups() {
[params, history, location.pathname]
);
const handleSearch = useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
const handleSourceFilter = useCallback(
(source: string | null | undefined) => updateParams("source", source ?? ""),
[updateParams]
);
const handleSearch = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setQuery(value);
},
[]
);
const handleNewGroup = useCallback(() => {
dialogs.openModal({
@@ -108,9 +135,9 @@ function Groups() {
}, [t, error]);
useEffect(() => {
const timeout = setTimeout(() => updateQuery(query), 250);
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateQuery]);
}, [query, updateParams]);
return (
<Scene
@@ -144,11 +171,18 @@ function Groups() {
) : (
<>
<StickyFilters>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<HStack>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeGroupSourceFilter
activeKey={reqParams.source ?? ""}
onSelect={handleSourceFilter}
/>
</HStack>
</StickyFilters>
<ConditionalFade animate={!data}>
<GroupsTable
@@ -167,4 +201,8 @@ function Groups() {
);
}
const LargeGroupSourceFilter = styled(GroupSourceFilter)`
height: 32px;
`;
export default observer(Groups);
+46 -23
View File
@@ -1,4 +1,5 @@
import debounce from "lodash/debounce";
import { runInAction } from "mobx";
import { observer } from "mobx-react";
import {
AcademicCapIcon,
@@ -24,19 +25,14 @@ import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import { HStack } from "~/components/primitives/HStack";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import { client } from "~/utils/ApiClient";
import isCloudHosted from "~/utils/isCloudHosted";
import SettingRow from "./components/SettingRow";
function Notifications() {
const user = useCurrentUser();
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(team.id);
const options = [
{
@@ -151,6 +147,8 @@ function Notifications() {
},
];
const visibleOptions = options.filter((o) => o.visible !== false);
const showSuccessMessage = debounce(() => {
toast.success(t("Notifications saved"));
}, 500);
@@ -163,6 +161,29 @@ function Notifications() {
[user, showSuccessMessage]
);
const handleToggleAll = React.useCallback(
async (checked: boolean) => {
runInAction(() => {
const updated = { ...user.notificationSettings };
for (const option of visibleOptions) {
updated[option.event] = checked;
}
user.notificationSettings = updated;
});
await client.post(
checked
? `/users.notificationsSubscribe`
: `/users.notificationsUnsubscribe`
);
showSuccessMessage();
},
[user, visibleOptions, showSuccessMessage]
);
const allEnabled = visibleOptions.every((o) =>
user.subscribedToEventType(o.event)
);
const showSuccessNotice = window.location.search === "?success";
return (
@@ -180,17 +201,18 @@ function Notifications() {
<Trans>Manage when and where you receive email notifications.</Trans>
</Text>
{env.EMAIL_ENABLED && can.manage && (
<Notice>
<Trans>
The email integration is currently disabled. Please set the
associated environment variables and restart the server to enable
notifications.
</Trans>
</Notice>
)}
<h2>{t("Notifications")}</h2>
<SettingRow
name="allNotifications"
label={t("All notifications")}
compact
border={false}
>
<Switch
id="allNotifications"
checked={allEnabled}
onChange={handleToggleAll}
/>
</SettingRow>
{options.map((option) => {
const setting = user.subscribedToEventType(option.event);
@@ -199,13 +221,14 @@ function Notifications() {
<SettingRow
key={option.event}
visible={option.visible}
label={
<HStack spacing={4}>
{option.icon} {option.title}
</HStack>
}
label={option.title}
name={option.event}
description={option.description}
description={
<Text size="small" type="secondary">
{option.description}
</Text>
}
compact
>
<Switch
key={option.event}
+20 -204
View File
@@ -1,6 +1,5 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -16,8 +15,6 @@ import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import type { Item } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import PlaceholderList from "~/components/List/Placeholder";
import PaginatedList from "~/components/PaginatedList";
import { ListItem } from "~/components/Sharing/components/ListItem";
@@ -34,6 +31,8 @@ import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import type GroupUser from "~/models/GroupUser";
import Switch from "~/components/Switch";
import history from "~/utils/history";
import { settingsPath } from "~/utils/routeHelpers";
type Props = {
group: Group;
@@ -63,17 +62,14 @@ export function CreateGroupDialog() {
try {
await group.save();
dialogs.closeAllModals();
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={group} />,
});
history.push(settingsPath("groups", group.id, "members"));
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[t, dialogs, groups, name, description]
[dialogs, groups, name, description]
);
return (
@@ -155,10 +151,17 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
<Trans>
You can edit the name of this group at any time, however doing so too
often might confuse your team mates.
</Trans>
{group.isExternallyManaged ? (
<Trans>
This group is managed by an external authentication provider. The
name is synced automatically and cannot be changed.
</Trans>
) : (
<Trans>
You can edit the name of this group at any time, however doing so
too often might confuse your team mates.
</Trans>
)}
</Text>
<Flex column>
<Input
@@ -166,6 +169,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
label={t("Name")}
onChange={handleNameChange}
value={name}
disabled={group.isExternallyManaged}
required
autoFocus
flex
@@ -181,7 +185,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
/>
<Switch
id="mentions"
label={t("Disable mentions")}
label={t("Hidden")}
note={t(
"Prevent this group from being mentionable in documents or comments"
)}
@@ -225,195 +229,7 @@ export function DeleteGroupDialog({ group, onSubmit }: Props) {
);
}
export const ViewGroupMembersDialog = observer(function ({
group,
}: Pick<Props, "group">) {
const { dialogs, users, groupUsers } = useStores();
const { t } = useTranslation();
const can = usePolicy(group);
const [query, setQuery] = React.useState("");
const [permissionFilter, setPermissionFilter] = React.useState<
GroupPermission | "all"
>("all");
const handleAddPeople = React.useCallback(() => {
dialogs.openModal({
title: t(`Add people to {{groupName}}`, {
groupName: group.name,
}),
content: <AddPeopleToGroupDialog group={group} />,
replace: true,
});
}, [t, group, dialogs]);
const handleRemoveUser = React.useCallback(
async (user: User) => {
try {
await groupUsers.delete({
groupId: group.id,
userId: user.id,
});
toast.success(
t(`{{userName}} was removed from the group`, {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} catch (_err) {
toast.error(t("Could not remove user"));
}
},
[t, groupUsers, group.id]
);
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
},
[]
);
const handlePermissionFilterChange = React.useCallback((value: string) => {
setPermissionFilter(value as GroupPermission | "all");
}, []);
const permissionOptions: Item[] = React.useMemo(
() => [
{
type: "item",
label: t("All permissions"),
value: "all",
},
{
type: "item",
label: t("Group admin"),
value: GroupPermission.Admin,
},
{
type: "item",
label: t("Member"),
value: GroupPermission.Member,
},
],
[t]
);
const filteredUsers = React.useMemo(() => {
let result = users.inGroup(group.id, query);
if (permissionFilter !== "all") {
const groupUserMap = new Map(
groupUsers.orderedData
.filter((gu) => gu.groupId === group.id)
.map((gu) => [gu.userId, gu])
);
result = result.filter((user) => {
const groupUser = groupUserMap.get(user.id);
return groupUser?.permission === permissionFilter;
});
}
return result;
}, [users, group.id, query, permissionFilter, groupUsers.orderedData]);
const hasActiveFilters = query || permissionFilter !== "all";
return (
<Flex column>
{can.update ? (
<>
<Text as="p" type="secondary">
<Trans
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
values={{
groupName: group.name,
}}
components={{
em: <strong />,
}}
/>
</Text>
{can.update && (
<span>
<Button
type="button"
onClick={handleAddPeople}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</span>
)}
<br />
</>
) : (
<Text as="p" type="secondary">
<Trans
defaults="Listing members of the <em>{{groupName}}</em> group."
values={{
groupName: group.name,
}}
components={{
em: <strong />,
}}
/>
</Text>
)}
{(filteredUsers.length || hasActiveFilters) && (
<Flex gap={8}>
<Input
type="search"
placeholder={`${t("Search by name")}`}
value={query}
onChange={handleFilter}
label={t("Search members")}
labelHidden
flex
/>
<InputSelect
options={permissionOptions}
value={permissionFilter}
onChange={handlePermissionFilterChange}
label={t("Filter by permissions")}
hideLabel
short
/>
</Flex>
)}
<PaginatedList<User>
items={filteredUsers}
fetch={groupUsers.fetchPage}
options={{
id: group.id,
}}
empty={
hasActiveFilters ? (
<Empty>{t("No members matching your filters")}</Empty>
) : (
<Empty>{t("This group has no members.")}</Empty>
)
}
renderItem={(user) => (
<GroupMemberListItem
key={user.id}
user={user}
group={group}
groupUser={groupUsers.orderedData.find(
(gu) => gu.userId === user.id && gu.groupId === group.id
)}
onRemove={can.update ? () => handleRemoveUser(user) : undefined}
/>
)}
/>
</Flex>
);
});
const AddPeopleToGroupDialog = observer(function ({
export const AddPeopleToGroupDialog = observer(function ({
group,
}: Pick<Props, "group">) {
const { dialogs, users, groupUsers } = useStores();
@@ -581,7 +397,7 @@ const GroupMemberListItem = observer(function ({
</Trans>
) : (
t("Never signed in")
)}
)}{" "}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
@@ -619,7 +435,7 @@ const GroupMemberListItem = observer(function ({
}
return true;
}}
disabled={!can.update}
disabled={!can.update || group.isExternallyManaged}
value={groupUser?.permission}
/>
</div>
@@ -0,0 +1,190 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { GroupPermission } from "@shared/types";
import type Group from "~/models/Group";
import type User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
import { HEADER_HEIGHT } from "~/components/Header";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Text from "~/components/Text";
import Time from "~/components/Time";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { FILTER_HEIGHT } from "./StickyFilters";
import { HStack } from "~/components/primitives/HStack";
import { VStack } from "~/components/primitives/VStack";
const ROW_HEIGHT = 50;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
group: Group;
};
/**
* Table component for displaying group members with permission management.
*/
export const GroupMembersTable = observer(function GroupMembersTable({
group,
...rest
}: Props) {
const { t } = useTranslation();
const { groupUsers } = useStores();
const can = usePolicy(group);
const permissions = useMemo(
() =>
[
{
label: t("Group admin"),
value: GroupPermission.Admin,
},
{
label: t("Member"),
value: GroupPermission.Member,
},
{
divider: true,
label: t("Remove"),
value: EmptySelectValue,
},
] as Permission[],
[t]
);
const handlePermissionChange = useCallback(
async (
user: User,
permission: GroupPermission | typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await groupUsers.delete({
userId: user.id,
groupId: group.id,
});
toast.success(
t(`{{userName}} was removed from the group`, {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} else {
await groupUsers.update({
userId: user.id,
groupId: group.id,
permission,
});
}
} catch (err) {
toast.error((err as Error).message);
return false;
}
return true;
},
[t, groupUsers, group.id]
);
const columns = useMemo<TableColumn<User>[]>(
() =>
compact<TableColumn<User>>([
{
type: "data",
id: "name",
header: t("Name"),
accessor: (user) => user.name,
component: (user) => (
<HStack>
<Avatar model={user} size={AvatarSize.Large} />
<VStack align="flex-start" spacing={0}>
<Text selectable>{user.name}</Text>
<Text type="tertiary" size="small">
{user.email}
</Text>
</VStack>
{user.isAdmin && <Badge primary>{t("Admin")}</Badge>}
</HStack>
),
width: "3fr",
},
{
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) => (
<HStack spacing={4} wrap>
{user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : (
<Text type="tertiary">{t("Never signed in")}</Text>
)}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
</HStack>
),
width: "1fr",
},
can.update
? {
type: "data",
id: "permission",
header: t("Permission"),
accessor: (user) => {
const gu = groupUsers.orderedData.find(
(m) => m.userId === user.id && m.groupId === group.id
);
return gu?.permission ?? "";
},
component: (user: User) => (
<InputMemberPermissionSelect
permissions={permissions}
disabled={group.isExternallyManaged}
onChange={(permission) =>
handlePermissionChange(
user,
permission as GroupPermission | typeof EmptySelectValue
)
}
value={
groupUsers.orderedData.find(
(m) => m.userId === user.id && m.groupId === group.id
)?.permission
}
/>
),
width: "130px",
}
: undefined,
]),
[
t,
can.update,
group.id,
groupUsers.orderedData,
permissions,
handlePermissionChange,
]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
{...rest}
/>
);
});
@@ -0,0 +1,47 @@
import { observer } from "mobx-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { GroupPermission } from "@shared/types";
import FilterOptions from "~/components/FilterOptions";
type Props = {
activeKey: string;
onSelect: (key: string | null | undefined) => void;
};
/**
* Filter component for group member permissions.
*/
const GroupPermissionFilter = ({ activeKey, onSelect, ...rest }: Props) => {
const { t } = useTranslation();
const options = useMemo(
() => [
{
key: "",
label: t("All permissions"),
},
{
key: GroupPermission.Admin,
label: t("Group admin"),
},
{
key: GroupPermission.Member,
label: t("Member"),
},
],
[t]
);
return (
<FilterOptions
options={options}
selectedKeys={[activeKey]}
onSelect={onSelect}
defaultLabel={t("All permissions")}
{...rest}
/>
);
};
export default observer(GroupPermissionFilter);
@@ -0,0 +1,52 @@
import { observer } from "mobx-react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "~/components/FilterOptions";
import useStores from "~/hooks/useStores";
type Props = {
activeKey: string;
onSelect: (key: string | null | undefined) => void;
};
const GroupSourceFilter = ({ activeKey, onSelect, ...rest }: Props) => {
const { t } = useTranslation();
const { authenticationProviders } = useStores();
useEffect(() => {
void authenticationProviders.fetchPage({});
}, [authenticationProviders]);
const syncProviders = useMemo(
() =>
authenticationProviders.orderedData.filter(
(p) => p.settings?.groupSyncEnabled
),
[authenticationProviders.orderedData]
);
if (!syncProviders.length) {
return null;
}
const options = [
{ key: "", label: t("All sources") },
{ key: "manual", label: t("Manual") },
...syncProviders.map((p) => ({
key: p.name,
label: p.displayName,
})),
];
return (
<FilterOptions
options={options}
selectedKeys={[activeKey]}
onSelect={onSelect}
defaultLabel={t("All sources")}
{...rest}
/>
);
};
export default observer(GroupSourceFilter);
+45 -11
View File
@@ -1,10 +1,11 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import { GroupIcon, HiddenIcon } from "outline-icons";
import * as React from "react";
import { useCallback, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { s, hover } from "@shared/styles";
import type Group from "~/models/Group";
@@ -20,13 +21,13 @@ import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import GroupMenu from "~/menus/GroupMenu";
import { ViewGroupMembersDialog } from "./GroupDialogs";
import { FILTER_HEIGHT } from "./StickyFilters";
import NudeButton from "~/components/NudeButton";
import { AvatarSize } from "~/components/Avatar";
import { HStack } from "~/components/primitives/HStack";
import Tooltip from "~/components/Tooltip";
import { settingsPath } from "~/utils/routeHelpers";
const ROW_HEIGHT = 60;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
@@ -52,16 +53,14 @@ const GroupRowContextMenu = observer(function GroupRowContextMenu({
export function GroupsTable(props: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const theme = useTheme();
const history = useHistory();
const handleViewMembers = useCallback(
(group: Group) => {
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={group} />,
});
history.push(settingsPath("groups", group.id, "members"));
},
[t, dialogs]
[history]
);
const applyContextMenu = useCallback(
@@ -89,6 +88,14 @@ export function GroupsTable(props: Props) {
<Flex column>
<Title onClick={() => handleViewMembers(group)}>
{group.name}
{group.disableMentions && (
<>
{" "}
<Tooltip content={t("This group is hidden")}>
<HiddenIcon size={16} color={theme.textSecondary} />
</Tooltip>
</>
)}
</Title>
<Text type="tertiary" size="small" weight="normal">
<Trans
@@ -140,6 +147,33 @@ export function GroupsTable(props: Props) {
width: "1.5fr",
sortable: false,
},
{
type: "data",
id: "source",
header: t("Source"),
accessor: (group) => group.externalGroup?.displayName ?? "manual",
component: (group) =>
group.externalGroup ? (
<Flex column>
<Text type="secondary" size="small" weight="normal">
{group.externalGroup.displayName}
</Text>
{group.externalGroup.lastSyncedAt && (
<Text type="tertiary" size="xsmall" weight="normal">
<Trans>
Synced{" "}
<Time
dateTime={group.externalGroup.lastSyncedAt}
addSuffix
shorten
/>
</Trans>
</Text>
)}
</Flex>
) : null,
width: "1fr",
},
{
type: "data",
id: "createdAt",
@@ -158,7 +192,7 @@ export function GroupsTable(props: Props) {
width: "50px",
},
]),
[t, handleViewMembers]
[t, handleViewMembers, theme.textSecondary]
);
return (
@@ -56,7 +56,7 @@ const TemplateRowContextMenu = observer(function TemplateRowContextMenu({
export function TemplatesTable(props: Props) {
const { t } = useTranslation();
const handleOpen = (template: Template) => () => {
const handleOpen = (template: Template) => {
history.push(template.path);
};
@@ -122,7 +122,10 @@ export function TemplatesTable(props: Props) {
type: "action",
id: "action",
component: (template) => (
<TemplateMenu template={template} onEdit={handleOpen(template)} />
<TemplateMenu
template={template}
onEdit={() => handleOpen(template)}
/>
),
width: "50px",
},
+15
View File
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { useLocation, useParams } from "react-router-dom";
import styled, { ThemeProvider } from "styled-components";
import { s } from "@shared/styles";
import { isModKey } from "@shared/utils/keyboard";
import type { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
@@ -18,9 +19,11 @@ import Text from "~/components/Text";
import env from "~/env";
import useBuildTheme from "~/hooks/useBuildTheme";
import useCurrentUser from "~/hooks/useCurrentUser";
import useKeyDown from "~/hooks/useKeyDown";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { Theme } from "~/stores/UiStore";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, OfflineError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
@@ -151,6 +154,18 @@ function SharedScene() {
)
);
useKeyDown(
useCallback(
(ev: KeyboardEvent) => isModKey(ev) && ev.shiftKey && ev.code === "KeyL",
[]
),
useCallback(() => {
if (!ui.themeOverride) {
ui.setTheme(ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light);
}
}, [ui])
);
useEffect(() => {
if (!user) {
void changeLanguage(detectLanguage(), i18n);
+2 -2
View File
@@ -13,10 +13,10 @@ type DialogDefinition = {
};
export default class DialogsStore {
@observable
@observable.shallow
guide: DialogDefinition;
@observable
@observable.shallow
modalStack = new Map<string, DialogDefinition>();
openGuide = ({
+32 -9
View File
@@ -53,6 +53,9 @@ export default class DocumentsStore extends Store<Document> {
@observable
backlinks: Map<string, string[]> = new Map();
@observable
similar: Map<string, string[]> = new Map();
@observable
movingDocumentId: string | null | undefined;
@@ -254,16 +257,27 @@ export default class DocumentsStore extends Store<Document> {
}
@action
fetchBacklinks = async (documentId: string): Promise<void> => {
const documents = await this.fetchAll({
backlinkDocumentId: documentId,
});
fetchRelationships = async (documentId: string): Promise<void> => {
const res = await client.post("/relationships.list", { documentId });
invariant(res?.data, "Relationships not available");
runInAction("DocumentsStore#fetchBacklinks", () => {
this.backlinks.set(
documentId,
documents.map((doc) => doc.id)
);
runInAction("DocumentsStore#fetchRelationships", () => {
res.data.documents.forEach(this.add);
this.addPolicies(res.policies);
const backlinkIds: string[] = [];
const similarIds: string[] = [];
for (const relationship of res.data.relationships) {
if (relationship.type === "backlink") {
backlinkIds.push(relationship.reverseDocumentId);
} else if (relationship.type === "similar") {
similarIds.push(relationship.reverseDocumentId);
}
}
this.backlinks.set(documentId, backlinkIds);
this.similar.set(documentId, similarIds);
});
};
@@ -276,6 +290,15 @@ export default class DocumentsStore extends Store<Document> {
);
}
getSimilarDocuments(documentId: string): Document[] {
const documentIds = this.similar.get(documentId) || [];
return orderBy(
compact(documentIds.map((id) => this.data.get(id))),
"title",
"asc"
);
}
@action
fetchChildDocuments = async (documentId: string): Promise<void> => {
const res = await client.post(`/documents.list`, {
+27
View File
@@ -1,6 +1,7 @@
import { action, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
import type { ProsemirrorData } from "@shared/types";
import Storage from "@shared/utils/Storage";
import Document from "~/models/Document";
import type Model from "~/models/base/Model";
@@ -92,6 +93,32 @@ class UiStore {
@observable
debugSafeArea = false;
/** Data for the currently active presentation, if any. */
@observable
presentationData: {
title: string;
icon?: string | null;
color?: string | null;
data: ProsemirrorData;
} | null = null;
/**
* Enter presentation mode for the given document.
*
* @param document the document to present, or null to exit.
*/
@action
setPresentingDocument = (document: Document | null): void => {
this.presentationData = document
? {
title: document.title,
icon: document.icon,
color: document.color,
data: document.data,
}
: null;
};
/** Tracks active export toasts for in-place updates when export completes */
exportToasts = observable.map<
string,
+52
View File
@@ -0,0 +1,52 @@
declare module "web-haptics" {
interface Vibration {
duration: number;
intensity?: number;
delay?: number;
}
type HapticPattern = number[] | Vibration[];
interface HapticPreset {
pattern: Vibration[];
}
type HapticInput = number | string | HapticPattern | HapticPreset;
interface TriggerOptions {
intensity?: number;
}
interface WebHapticsOptions {
debug?: boolean;
showSwitch?: boolean;
}
export {
HapticInput,
HapticPattern,
HapticPreset,
TriggerOptions,
Vibration,
WebHapticsOptions,
};
}
declare module "web-haptics/react" {
import type {
HapticInput,
TriggerOptions,
WebHapticsOptions,
} from "web-haptics";
function useWebHaptics(options?: WebHapticsOptions): {
trigger: (
input?: HapticInput,
options?: TriggerOptions
) => Promise<void> | undefined;
cancel: () => void | undefined;
isSupported: boolean;
};
export { useWebHaptics };
}
+3
View File
@@ -4,6 +4,8 @@ import queryString from "query-string";
import EDITOR_VERSION from "@shared/editor/version";
import type { JSONObject } from "@shared/types";
import { Scope } from "@shared/types";
import { version } from "../../package.json";
import env from "~/env";
import stores from "~/stores";
import Logger from "./Logger";
import download from "./download";
@@ -109,6 +111,7 @@ class ApiClient {
"cache-control": "no-cache",
"x-editor-version": EDITOR_VERSION,
"x-api-version": "4",
"x-client-version": env.VERSION ? `${version}-${env.VERSION}` : version,
pragma: "no-cache",
...options?.headers,
};
+1 -1
View File
@@ -95,7 +95,7 @@ export class PluginManager {
}
if (!this.plugins.has(plugin.type)) {
this.plugins.set(plugin.type, observable.array([]));
this.plugins.set(plugin.type, observable.array([], { deep: false }));
}
this.plugins
+8
View File
@@ -0,0 +1,8 @@
/**
* Calls preventDefault on the event. Useful as a stable callback reference.
*
* @param event the event to prevent default on.
*/
export const preventDefault = (event: { preventDefault: () => void }) => {
event.preventDefault();
};
+51 -10
View File
@@ -2,6 +2,19 @@ import type { IntegrationSettings, IntegrationType } from "@shared/types";
import { IntegrationService, MentionType } from "@shared/types";
import type Integration from "~/models/Integration";
const gitlabSystemPaths = new Set([
"explore",
"help",
"admin",
"dashboard",
"users",
"groups",
"projects",
"snippets",
"search",
"-",
]);
export const isURLMentionable = ({
url,
integration,
@@ -30,9 +43,15 @@ export const isURLMentionable = ({
case IntegrationService.GitLab: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
const gitlabHostname = settings.gitlab?.url
? new URL(settings.gitlab?.url).hostname
: undefined;
let gitlabHostname: string | undefined;
try {
gitlabHostname = settings.gitlab?.url
? new URL(settings.gitlab.url).hostname
: undefined;
} catch {
// Invalid URL stored in settings
return false;
}
return hostname === "gitlab.com" || hostname === gitlabHostname;
}
@@ -59,20 +78,42 @@ export const determineMentionType = ({
? MentionType.PullRequest
: type === "issues"
? MentionType.Issue
: undefined;
: type === "projects"
? MentionType.Project
: undefined;
}
case IntegrationService.Linear: {
const type = pathParts[2];
return type === "issue" ? MentionType.Issue : undefined;
return type === "issue"
? MentionType.Issue
: type === "project"
? MentionType.Project
: undefined;
}
case IntegrationService.GitLab: {
return pathname.includes("merge_requests")
? MentionType.PullRequest
: pathname.includes("issues")
? MentionType.Issue
: undefined;
const hasShowParam = url.searchParams.has("show");
if (
/\/-\/merge_requests\/\d+/.test(pathname) ||
(/\/-\/merge_requests\/?$/.test(pathname) && hasShowParam)
) {
return MentionType.PullRequest;
}
if (
/\/-\/(issues|work_items)\/\d+/.test(pathname) ||
(/\/-\/(issues|work_items)\/?$/.test(pathname) && hasShowParam)
) {
return MentionType.Issue;
}
if (!pathname.includes("/-/")) {
const parts = pathname.split("/").filter(Boolean);
if (parts.length >= 2 && !gitlabSystemPaths.has(parts[0])) {
return MentionType.Project;
}
}
return undefined;
}
default:
+23 -1
View File
@@ -1,4 +1,4 @@
import { sharedModelPath } from "./routeHelpers";
import { sharedModelPath, desktopify } from "./routeHelpers";
describe("#sharedDocumentPath", () => {
test("should return share path for a document", () => {
@@ -12,3 +12,25 @@ describe("#sharedDocumentPath", () => {
);
});
});
describe("#desktopify", () => {
test("should replace https protocol with outline://", () => {
Object.defineProperty(window, "location", {
value: { origin: "https://app.getoutline.com" },
writable: true,
});
expect(desktopify("/doc/test-DjDlkBi77t")).toBe(
"outline://app.getoutline.com/doc/test-DjDlkBi77t"
);
});
test("should replace http protocol with outline://", () => {
Object.defineProperty(window, "location", {
value: { origin: "http://localhost:3000" },
writable: true,
});
expect(desktopify("/doc/test-DjDlkBi77t")).toBe(
"outline://localhost:3000/doc/test-DjDlkBi77t"
);
});
});
+10
View File
@@ -179,6 +179,16 @@ export function urlify(path: string): string {
return `${window.location.origin}${path}`;
}
/**
* Converts a path to a desktop app URL using the outline:// protocol.
*
* @param path The path to convert.
* @returns The desktop app URL.
*/
export function desktopify(path: string): string {
return urlify(path).replace(/^https?:\/\//, "outline://");
}
export const matchCollectionSlug =
":collectionSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
+5 -3
View File
@@ -106,6 +106,7 @@
"@tanstack/react-virtual": "^3.13.12",
"@types/form-data": "^2.5.2",
"@types/mailparser": "^3.4.6",
"@types/pako": "^2.0.4",
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react-oxc": "^0.2.3",
"addressparser": "^1.0.1",
@@ -121,7 +122,6 @@
"copy-to-clipboard": "^3.3.3",
"core-js": "^3.45.1",
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"date-fns": "^3.6.0",
"dd-trace": "^5.82.0",
"diff": "^5.2.0",
@@ -138,6 +138,7 @@
"fs-extra": "^11.3.2",
"fuzzy-search": "^3.2.1",
"glob": "^8.1.0",
"hot-shots": "^12.1.0",
"http-errors": "2.0.1",
"https-proxy-agent": "^7.0.6",
"i18next": "^22.5.1",
@@ -179,7 +180,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^7.0.11",
"octokit": "^3.2.2",
"outline-icons": "^4.1.0",
"outline-icons": "^4.3.0",
"oy-vey": "^0.12.1",
"pako": "^2.1.0",
"passport": "^0.7.0",
@@ -265,6 +266,7 @@
"vaul": "^1.1.2",
"vite": "npm:rolldown-vite@7.3.0",
"vite-plugin-pwa": "1.0.3",
"web-haptics": "^0.0.6",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
@@ -390,6 +392,6 @@
"cheerio": "1.0.0-rc.12",
"zod": "^4.2.1"
},
"version": "1.5.0",
"version": "1.6.1",
"packageManager": "yarn@4.11.0"
}
+2 -2
View File
@@ -51,7 +51,8 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
store: new StateStore(),
state: true,
callbackURL: `${env.URL}/auth/${config.id}.callback`,
authorizationURL: "https://discord.com/api/oauth2/authorize",
authorizationURL:
"https://discord.com/api/oauth2/authorize?prompt=none",
tokenURL: "https://discord.com/api/oauth2/token",
pkce: false,
},
@@ -227,7 +228,6 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
config.id,
passport.authenticate(config.id, {
scope,
prompt: "consent",
})
);
router.get(`${config.id}.callback`, passportMiddleware(config.id));
+3
View File
@@ -80,6 +80,7 @@ router.post(
// send email to users email address with a short-lived token and code
await new SigninEmail({
to: user.email,
language: user.language,
token,
teamUrl: team.url,
client,
@@ -171,6 +172,7 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
if (user.isInvited) {
await new WelcomeEmail({
to: user.email,
language: user.language,
role: user.role,
teamUrl: user.team.url,
}).schedule();
@@ -179,6 +181,7 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
if (inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)) {
await new InviteAcceptedEmail({
to: inviter.email,
language: inviter.language,
inviterId: inviter.id,
invitedName: user.name,
teamUrl: user.team.url,
+1
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import fetch from "@server/utils/fetch";
import env from "./env";
import { FigmaUtils } from "../shared/FigmaUtils";
import type { UnfurlSignature } from "@server/types";
+171 -11
View File
@@ -13,7 +13,11 @@ import { IntegrationService, UnfurlResourceType } from "@shared/types";
import Logger from "@server/logging/Logger";
import type { User } from "@server/models";
import { Integration } from "@server/models";
import type { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
import type {
UnfurlIssueOrPR,
UnfurlProject,
UnfurlSignature,
} from "@server/types";
import { GitHubUtils } from "../shared/GitHubUtils";
import env from "./env";
@@ -24,6 +28,33 @@ type Issue =
type Installation =
Endpoints["GET /app/installations/{installation_id}"]["response"]["data"];
type ParsedIssueOrPR = {
owner: string;
repo: string;
type: UnfurlResourceType.Issue | UnfurlResourceType.PR;
id: number;
url: string;
};
type ParsedProject = {
owner: string;
ownerType: "orgs" | "users";
type: UnfurlResourceType.Project;
projectNumber: number;
url: string;
};
type GitHubResource = ParsedIssueOrPR | ParsedProject;
type GitHubProject = {
number: number;
title: string;
description: string | null;
url: string;
createdAt: string;
closed: boolean;
};
const requestPlugin = (octokit: Octokit) => ({
requestRepos: () =>
octokit.paginate.iterator(
@@ -36,7 +67,7 @@ const requestPlugin = (octokit: Octokit) => ({
}
),
requestPR: async (params: NonNullable<ReturnType<typeof GitHub.parseUrl>>) =>
requestPR: async (params: ParsedIssueOrPR) =>
octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, {
owner: params.owner,
repo: params.repo,
@@ -47,9 +78,7 @@ const requestPlugin = (octokit: Octokit) => ({
},
}),
requestIssue: async (
params: NonNullable<ReturnType<typeof GitHub.parseUrl>>
) =>
requestIssue: async (params: ParsedIssueOrPR) =>
octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, {
owner: params.owner,
repo: params.repo,
@@ -60,6 +89,61 @@ const requestPlugin = (octokit: Octokit) => ({
},
}),
/**
* Fetches details of a GitHub ProjectV2 using the GraphQL API.
*
* @param params Parsed project URL identifiers.
* @returns Project data or undefined if not found.
*/
requestProject: async (
params: ParsedProject
): Promise<GitHubProject | undefined> => {
const ownerField = params.ownerType === "orgs" ? "organization" : "user";
const query = `query($login: String!, $number: Int!) {
${ownerField}(login: $login) {
projectV2(number: $number) {
number
title
shortDescription
url
createdAt
closed
}
}
}`;
const result = await octokit.graphql<
Record<
string,
{
projectV2: {
number: number;
title: string;
shortDescription: string | null;
url: string;
createdAt: string;
closed: boolean;
} | null;
}
>
>(query, { login: params.owner, number: params.projectNumber });
const project = result[ownerField]?.projectV2;
if (!project) {
return undefined;
}
return {
number: project.number,
title: project.title,
description: project.shortDescription,
url: project.url,
createdAt: project.createdAt,
closed: project.closed,
};
},
/**
* Fetches app installations accessible to the user
*
@@ -75,7 +159,7 @@ const requestPlugin = (octokit: Octokit) => ({
* @returns Response containing resource details
*/
requestResource: async function requestResource(
resource: ReturnType<typeof GitHub.parseUrl>
resource: GitHubResource | undefined
): Promise<OctokitResponse<Issue | PR> | undefined> {
switch (resource?.type) {
case UnfurlResourceType.PR:
@@ -139,7 +223,7 @@ export class GitHub {
* @param url URL to parse
* @returns {object} Containing resource identifiers - `owner`, `repo`, `type` and `id`.
*/
public static parseUrl(url: string) {
public static parseUrl(url: string): GitHubResource | undefined {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== "github.com") {
@@ -147,6 +231,29 @@ export class GitHub {
}
const parts = pathname.split("/");
// Handle project URLs: /orgs/{org}/projects/{number} or /users/{user}/projects/{number}
if (
(parts[1] === "orgs" || parts[1] === "users") &&
parts[3] === "projects"
) {
const ownerType = parts[1] as "orgs" | "users";
const owner = parts[2];
const projectNumber = Number(parts[4]);
if (!owner || isNaN(projectNumber)) {
return;
}
return {
owner,
ownerType,
type: UnfurlResourceType.Project,
projectNumber,
url,
};
}
const owner = parts[1];
const repo = parts[2];
const type = parts[3]
@@ -154,11 +261,17 @@ export class GitHub {
: undefined;
const id = Number(parts[4]);
if (!type || !GitHub.supportedResources.includes(type)) {
if (!type || !GitHub.supportedResources.includes(type) || isNaN(id)) {
return;
}
return { owner, repo, type, id, url };
return {
owner,
repo,
type: type as UnfurlResourceType.Issue | UnfurlResourceType.PR,
id,
url,
};
} catch (_err) {
// Invalid URL format
return;
@@ -261,6 +374,10 @@ export class GitHub {
integration.settings.github!.installation.id
);
if (resource.type === UnfurlResourceType.Project) {
return GitHub.unfurlProject(client, resource);
}
const res = await client.requestResource(resource);
if (!res) {
return { error: "Resource not found" };
@@ -273,9 +390,52 @@ export class GitHub {
}
};
private static async unfurlProject(
client: InstanceType<typeof CustomOctokit>,
resource: ParsedProject
) {
let project: GitHubProject | undefined;
try {
project = await client.requestProject(resource);
} catch (err) {
Logger.warn("Failed to fetch project from GitHub", err);
return { error: "Resource not found" };
}
if (!project) {
return { error: "Resource not found" };
}
const state = project.closed ? "completed" : "open";
return {
type: UnfurlResourceType.Project,
url: project.url,
id: `#${project.number}`,
name: project.title,
color: GitHubUtils.getColorForStatus(state),
description: project.description,
lead: null,
state: {
type: state,
name: state,
color: GitHubUtils.getColorForStatus(state),
},
labels: [],
createdAt: project.createdAt,
targetDate: null,
} satisfies UnfurlProject;
}
private static transformData(data: Issue | PR, type: UnfurlResourceType) {
if (type === UnfurlResourceType.Issue) {
const issue = data as Issue;
const issueState =
issue.state === "closed"
? issue.state_reason === "completed"
? "completed"
: "canceled"
: issue.state;
return {
type: UnfurlResourceType.Issue,
url: issue.html_url,
@@ -291,8 +451,8 @@ export class GitHub {
color: `#${label.color}`,
})),
state: {
name: issue.state,
color: GitHubUtils.getColorForStatus(issue.state),
name: issueState,
color: GitHubUtils.getColorForStatus(issueState),
},
createdAt: issue.created_at,
} satisfies UnfurlIssueOrPR;
+1
View File
@@ -53,6 +53,7 @@ export class GitHubUtils {
return "#a371f7";
case "closed":
return "#f85149";
case "completed":
case "merged":
return "#8250df";
case "canceled":
+55 -5
View File
@@ -2,6 +2,8 @@ import { Gitlab } from "@gitbeaker/rest";
import type {
IssueSchemaWithExpandedLabels,
MergeRequestSchema,
ProjectSchema,
StatisticsSchema,
} from "@gitbeaker/rest";
import z from "zod";
import {
@@ -12,7 +14,11 @@ import {
import Logger from "@server/logging/Logger";
import type { User } from "@server/models";
import { Integration, IntegrationAuthentication } from "@server/models";
import type { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
import type {
UnfurlIssueOrPR,
UnfurlProject,
UnfurlSignature,
} from "@server/types";
import fetch from "@server/utils/fetch";
import { validateUrlNotPrivate } from "@server/utils/url";
import { GitLabUtils } from "../shared/GitLabUtils";
@@ -208,10 +214,6 @@ export class GitLab {
}
if (!resource) {
Logger.debug(
"plugins",
`Could not parse GitLab resource from URL: ${url}`
);
return;
}
@@ -254,6 +256,13 @@ export class GitLab {
customUrl
);
return this.transformMR(mr);
} else if (resource.type === UnfurlResourceType.Project) {
const client = await this.createClient(token, customUrl);
const [project, issueStats] = await Promise.all([
client.Projects.show(projectPath),
client.IssuesStatistics.all({ projectId: projectPath }),
]);
return this.transformProject(project, issueStats);
}
return { error: "Resource not found" };
@@ -390,4 +399,45 @@ export class GitLab {
createdAt: mr.created_at,
} satisfies UnfurlIssueOrPR;
}
private static transformProject(
project: ProjectSchema,
issueStats: StatisticsSchema
) {
const visibility = project.visibility ?? "private";
const owner = project.owner as
| { name: string; avatar_url?: string }
| undefined;
const { opened, closed } = issueStats.statistics.counts;
const total = opened + closed;
const progress = total > 0 ? closed / total : 0;
return {
type: UnfurlResourceType.Project,
url: project.web_url,
id: String(project.id),
name: project.name,
color: GitLabUtils.getColorForProject(project.id),
avatarUrl: project.avatar_url || undefined,
description: project.description ?? null,
lead: owner
? {
name: owner.name,
avatarUrl: owner.avatar_url ?? "",
}
: null,
state: {
type: visibility,
name: visibility.charAt(0).toUpperCase() + visibility.slice(1),
color: GitLabUtils.getColorForVisibility(visibility),
},
labels: (project.topics ?? []).map((topic: string) => ({
name: topic,
color: "#6B7280",
})),
progress,
createdAt: project.created_at,
targetDate: null,
} satisfies UnfurlProject;
}
}
+114 -1
View File
@@ -42,6 +42,32 @@ describe("GitLabUtils.parseUrl", () => {
});
});
it("should parse a work_items URL", () => {
const result = GitLabUtils.parseUrl(
"https://gitlab.com/speak/purser/-/work_items/39"
);
expect(result).toEqual({
owner: "speak",
repo: "purser",
type: UnfurlResourceType.Issue,
id: 39,
url: "https://gitlab.com/speak/purser/-/work_items/39",
});
});
it("should parse a nested group work_items URL", () => {
const result = GitLabUtils.parseUrl(
"https://gitlab.com/group/subgroup/repo/-/work_items/5"
);
expect(result).toEqual({
owner: "group/subgroup",
repo: "repo",
type: UnfurlResourceType.Issue,
id: 5,
url: "https://gitlab.com/group/subgroup/repo/-/work_items/5",
});
});
it("should return undefined for unsupported resource type", () => {
const result = GitLabUtils.parseUrl(
"https://gitlab.com/speak/purser/-/pipelines/100"
@@ -49,8 +75,18 @@ describe("GitLabUtils.parseUrl", () => {
expect(result).toBeUndefined();
});
it("should return undefined for a URL with too few path segments", () => {
it("should parse a project URL", () => {
const result = GitLabUtils.parseUrl("https://gitlab.com/speak/purser");
expect(result).toEqual({
owner: "speak",
repo: "purser",
type: UnfurlResourceType.Project,
url: "https://gitlab.com/speak/purser",
});
});
it("should return undefined for a URL with too few path segments", () => {
const result = GitLabUtils.parseUrl("https://gitlab.com/speak");
expect(result).toBeUndefined();
});
@@ -60,6 +96,54 @@ describe("GitLabUtils.parseUrl", () => {
);
expect(result).toBeUndefined();
});
it("should return undefined for an issues list URL without an ID", () => {
const result = GitLabUtils.parseUrl(
"https://gitlab.com/speak/purser/-/issues"
);
expect(result).toBeUndefined();
});
it("should parse a nested group project URL", () => {
const result = GitLabUtils.parseUrl(
"https://gitlab.com/group/subgroup/repo"
);
expect(result).toEqual({
owner: "group/subgroup",
repo: "repo",
type: UnfurlResourceType.Project,
url: "https://gitlab.com/group/subgroup/repo",
});
});
it("should return undefined for an invalid custom URL", () => {
const result = GitLabUtils.parseUrl(
"https://gitlab.example.com/team/project/-/issues/10",
"not-a-valid-url"
);
expect(result).toBeUndefined();
});
it("should return undefined for system paths", () => {
expect(
GitLabUtils.parseUrl("https://gitlab.com/explore/projects")
).toBeUndefined();
expect(
GitLabUtils.parseUrl("https://gitlab.com/help/topics")
).toBeUndefined();
expect(
GitLabUtils.parseUrl("https://gitlab.com/admin/users")
).toBeUndefined();
expect(
GitLabUtils.parseUrl("https://gitlab.com/dashboard/projects")
).toBeUndefined();
expect(
GitLabUtils.parseUrl("https://gitlab.com/users/someone")
).toBeUndefined();
expect(
GitLabUtils.parseUrl("https://gitlab.com/groups/mygroup")
).toBeUndefined();
});
});
describe("base64 show parameter URLs", () => {
@@ -124,6 +208,22 @@ describe("GitLabUtils.parseUrl", () => {
});
});
it("should parse a work_items URL with show parameter", () => {
const show = btoa(
JSON.stringify({ iid: "39", full_path: "speak/purser", id: 1215135 })
);
const result = GitLabUtils.parseUrl(
`https://gitlab.com/speak/purser/-/work_items?show=${show}`
);
expect(result).toEqual({
owner: "speak",
repo: "purser",
type: UnfurlResourceType.Issue,
id: 39,
url: `https://gitlab.com/speak/purser/-/work_items?show=${show}`,
});
});
it("should return undefined for invalid base64 in show parameter", () => {
const result = GitLabUtils.parseUrl(
"https://gitlab.com/speak/purser/-/issues?show=not-valid-base64!!!"
@@ -155,6 +255,19 @@ describe("GitLabUtils.parseUrl", () => {
});
});
it("should parse a project URL with a custom URL", () => {
const result = GitLabUtils.parseUrl(
"https://git.example.com/team/project",
"https://git.example.com"
);
expect(result).toEqual({
owner: "team",
repo: "project",
type: UnfurlResourceType.Project,
url: "https://git.example.com/team/project",
});
});
it("should not match default gitlab.com when custom URL is set", () => {
const result = GitLabUtils.parseUrl(
"https://gitlab.com/speak/purser/-/issues/1",
+146 -52
View File
@@ -8,6 +8,7 @@ export class GitLabUtils {
private static supportedResources = [
UnfurlResourceType.Issue,
UnfurlResourceType.PR,
UnfurlResourceType.Project,
];
/**
@@ -103,27 +104,95 @@ export class GitLabUtils {
* @param customUrl - Optional custom GitLab URL from integration settings.
* @returns An object containing resource identifiers or undefined if the URL is invalid.
*/
public static parseUrl(url: string, customUrl?: string) {
const parsed = new URL(url);
const urlHostname = new URL(this.getGitlabUrl(customUrl)).hostname;
public static parseUrl(
url: string,
customUrl?: string
):
| {
owner: string;
repo: string | undefined;
type: UnfurlResourceType.Issue | UnfurlResourceType.PR;
id: number;
url: string;
}
| {
owner: string;
repo: string;
type: UnfurlResourceType.Project;
url: string;
}
| undefined {
try {
const parsed = new URL(url);
const urlHostname = new URL(this.getGitlabUrl(customUrl)).hostname;
if (parsed.hostname !== urlHostname) {
return;
}
if (parsed.hostname !== urlHostname) {
return;
}
const parts = parsed.pathname.split("/").filter(Boolean);
const parts = parsed.pathname.split("/").filter(Boolean);
// Try base64-encoded `show` query parameter first
// e.g. /owner/repo/-/issues?show=eyJ...
const showParam = parsed.searchParams.get("show");
if (showParam && parts.length >= 4) {
// Try base64-encoded `show` query parameter first
// e.g. /owner/repo/-/issues?show=eyJ...
const showParam = parsed.searchParams.get("show");
if (showParam && parts.length >= 4) {
const resourceType = parts.pop();
parts.pop(); // separator ("-")
const repo = parts.pop();
const owner = parts.join("/");
const type =
resourceType === "issues" || resourceType === "work_items"
? UnfurlResourceType.Issue
: resourceType === "merge_requests"
? UnfurlResourceType.PR
: undefined;
if (!type || !this.supportedResources.includes(type)) {
return;
}
try {
const decoded = JSON.parse(atob(decodeURIComponent(showParam)));
const iid = Number(decoded.iid);
if (!iid) {
return;
}
return { owner, repo, type, id: iid, url };
} catch {
return;
}
}
// Check if it's a project URL (no -/ separator pattern in path)
if (!parsed.pathname.includes("/-/")) {
if (parts.length >= 2 && !this.isSystemPath(parts[0])) {
const repo = parts[parts.length - 1];
const owner = parts.slice(0, -1).join("/");
return {
owner,
repo,
type: UnfurlResourceType.Project,
url,
};
}
return;
}
if (parts.length < 5) {
return;
}
// Direct URL: /owner/repo/-/issues/123 or /owner/repo/-/merge_requests/123
const resourceId = parts.pop();
const resourceType = parts.pop();
parts.pop(); // separator ("-")
const repo = parts.pop();
const owner = parts.join("/");
const type =
resourceType === "issues"
resourceType === "issues" || resourceType === "work_items"
? UnfurlResourceType.Issue
: resourceType === "merge_requests"
? UnfurlResourceType.PR
@@ -133,48 +202,38 @@ export class GitLabUtils {
return;
}
try {
const decoded = JSON.parse(atob(decodeURIComponent(showParam)));
const iid = Number(decoded.iid);
if (!iid) {
return;
}
return { owner, repo, type, id: iid, url };
} catch {
return;
}
}
if (parts.length < 5) {
return {
owner,
repo,
type,
id: Number(resourceId),
url,
};
} catch {
return;
}
}
// Direct URL: /owner/repo/-/issues/123 or /owner/repo/-/merge_requests/123
const resourceId = parts.pop();
const resourceType = parts.pop();
parts.pop(); // separator ("-")
const repo = parts.pop();
const owner = parts.join("/");
const type =
resourceType === "issues"
? UnfurlResourceType.Issue
: resourceType === "merge_requests"
? UnfurlResourceType.PR
: undefined;
if (!type || !this.supportedResources.includes(type)) {
return;
}
return {
owner,
repo,
type,
id: Number(resourceId),
url,
};
/**
* Checks if the first path segment is a known GitLab system path.
*
* @param segment - the first path segment of the URL.
* @returns true if the segment is a known system path.
*/
private static isSystemPath(segment: string): boolean {
const systemPaths = new Set([
"explore",
"help",
"admin",
"dashboard",
"users",
"groups",
"projects",
"snippets",
"search",
"-",
]);
return systemPaths.has(segment);
}
/**
@@ -195,7 +254,42 @@ export class GitLabUtils {
merged: "#8250df",
canceled: "#848d97",
};
return statusColors[status] ?? "#848d97";
}
/**
* Returns a deterministic color for a GitLab project based on its ID.
* Mirrors GitLab's identicon algorithm: (id % 7) mapped to a palette.
*
* @param projectId - the numeric project ID.
* @returns a hex color string.
*/
public static getColorForProject(projectId: number): string {
const palette = [
"#e05842", // red
"#a972cc", // purple
"#5b6abf", // indigo
"#3e8fda", // blue
"#42a68c", // teal
"#e67e3c", // orange
"#7e7e7e", // neutral
];
return palette[projectId % 7];
}
/**
* Returns the color associated with a given visibility level.
*
* @param visibility - The visibility level of the resource.
* @returns The color associated with the visibility level.
*/
public static getColorForVisibility(visibility: string): string {
const visibilityColors: Record<string, string> = {
public: "#1f75cb",
internal: "#f8ae1a",
private: "#848d97",
};
return visibilityColors[visibility] ?? "#848d97";
}
}

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