Compare commits

...

85 Commits

Author SHA1 Message Date
eliottreich cdd08bbf58 ci: pin third-party GitHub Actions to commit SHAs (#12662)
Pins all 12 third-party action references currently on mutable tags
to the commit each tag resolves to, across 4 workflow files, keeping
a # tag comment. Ref-only, no behavior change.
2026-06-12 22:01:26 -04:00
Tom Moor bda95e4952 feat: Date mentions (#12621)
* Add date mentions to the editor

Introduce a new "date" mention type alongside the existing user,
document and collection mentions. Typing @ with a natural language date
(e.g. "tomorrow", "next friday", "jan 2") surfaces a date suggestion,
parsed via chrono-node. Dates are stored as date-only ISO strings and
displayed with increasing granularity (Today / Tomorrow / January 2nd /
February 3rd, 2024), recomputed dynamically so relative labels stay
fresh. Clicking a date mention opens a Radix popover calendar to change
it.

* Load chrono-node lazily to keep it out of the main bundle

Convert parseNaturalLanguageDate to dynamically import chrono-node on
first use so the bundler splits it into a separate chunk fetched only
when a date is actually parsed. The mention menu now resolves the parse
asynchronously in an effect.

* Add DynamicCalendarIcon

* Lock page scroll while the date mention picker is open

Wrap the date picker popover content in RemoveScroll (via a Slot, with
the Radix content asChild), mirroring the inline editor menu, so the
page can't scroll behind the open calendar.

* Restyle the date mention calendar picker

The react-day-picker base stylesheet isn't loaded in the editor, so day
cells fell back to default browser button styling. Style the calendar
from scratch to match the rest of the app: reset button chrome, show
outside (previous/next month) days clearly de-emphasised, render the
selected day as a solid accent-filled circle, and emphasise today with
the accent colour. Enable showOutsideDays and fixedWeeks for a stable
6-week grid.

* Share one themed Calendar between the date mention and API key pickers

Extract the custom react-day-picker styling into a reusable Calendar
component and use it in both the date mention picker and the API key
expiry picker, so they look identical. The calendar owns its own padding
and the API key scene no longer needs the library's base stylesheet.

* Make the ISO date the single source of truth for date mentions

Date mentions no longer persist a human-readable label in the ProseMirror
data. Instead the displayed text, plaintext, DOM text and markdown link
text are all derived from the ISO modelId, so the saved data can never
drift or go stale. parseDOM/parseMarkdown no longer capture rendered text
as a label for dates, and the mention menu/picker stop writing one.

* tweaks

* Make DynamicCalendarIcon day text contrast with its fill

The day number is rendered white with mix-blend-mode difference, which
produces the exact inverse of the icon's (currentColor) fill, so it stays
legible whatever colour the icon takes. The SVG is isolated so the blend
only considers the icon's own fill.

* Lazy-load the date picker to keep Radix out of the editor schema graph

Importing @radix-ui/react-popover and react-day-picker at the top of
Mentions.tsx pulled them into the editor schema's static import graph,
which is also loaded on the server. Radix's prebuilt ESM does a bare
"react/jsx-runtime" import that the node/shared test resolvers can't
resolve, breaking all server and shared editor test suites.

Move the popover + calendar into DateMentionPicker, loaded via
React.lazy, so the browser-only dependencies are code-split out of the
schema graph and only fetched when an editable date mention renders.

* Deprecate block menu date/time commands in favor of date mention

Replace the block menu "Current date" entry so it inserts a date mention
for today instead of a static string/template token, and remove the
"Current time" and "Current date and time" entries. The underlying
DateTime extension and its {date}/{time}/{datetime} template placeholders
are left intact so existing documents and templates keep working.

* Omit the year from dateToReadable within the current year

dateToReadable now formats current-year dates without the year (e.g.
"June 8th") and includes it otherwise (e.g. "February 3rd, 2024"). This
keeps the mention menu subtitle compact while the relative title shows
"Today"/"Tomorrow".

* Let date mentions inherit surrounding font weight

The .mention style fixes font-weight to 500, which prevented a date
mention placed inside a heading from rendering bold like the rest of the
heading. Date mentions are plain text, so they now inherit the font
weight of their context.

* Address review feedback on date mentions

- MentionMenu: catch rejected date-parse promises so a chunk-load failure
  clears the suggestion instead of leaving stale state / an unhandled rejection.
- parseNaturalLanguageDate: don't cache a rejected chrono import so a later
  parse can retry after a transient failure.
- parseISODate: reject strings with a time component to honor the date-only
  contract and keep day-granular comparisons correct.
- DynamicCalendarIcon: mark the decorative SVG aria-hidden / focusable=false.

* tweaks

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-12 21:42:16 -04:00
Tom Moor 394c6e3b03 fix: Duplicate paths in export ZIP (#12674) 2026-06-12 20:04:18 -04:00
Tom Moor 9113501906 Add PROXY_HEADERS_TRUSTED env (#12676)
* Add PROXY_HEADERS_TRUSTED env

* Don't trust X-Forwarded-Proto for HTTPS redirect when proxy headers untrusted

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:58:16 -04:00
Tom Moor 92168c3641 fix: Toggling a nested list no longer converts parent lists (#12670)
* fix: Toggling a nested list no longer converts parent lists

When the selection was inside a nested list, toggling the list type from
the toolbar or keyboard shortcut converted every list in the tree,
including ancestors of the selected list. This was caused by
doc.nodesBetween visiting ancestor nodes whose range overlaps the
selected list - these are now skipped so only the closest list and its
children are converted. Also guards against converting nested lists with
incompatible content such as checkbox lists.

Closes #12653

https://claude.ai/code/session_01Q5hkRNp1Fo3jAc9fW5t68h

* test: Throw when selection text is not found in toggleList test helper

https://claude.ai/code/session_01Q5hkRNp1Fo3jAc9fW5t68h

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-12 19:37:38 -04:00
Tom Moor 5ea63aa1a2 fix: Editor math block parsing and NaN media dimensions (#12668)
* fix: Block math not closed by trailing $$ on a content line

The closing delimiter check compared a 3-character slice against the
2-character "$$" delimiter, so block math closed on the same line as
content (e.g. "c = d$$") was never detected and the block swallowed the
rest of the document. Use the delimiter length rather than a hardcoded
slice. Also fix the indexOf sentinel comparison (!== 1 instead of
!== -1) in inline math parsing, which terminated correctly only by
coincidence.

Adds tests for the math markdown rules and moves the findNodes test
helper into shared/test/editor for reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: NaN width and height parsed for video and image nodes

Video parseDOM and parseMarkdown used parseInt on a missing attribute,
storing NaN instead of null and persisting it to markdown as NaNxNaN.
Image size syntax with a missing dimension (e.g. "=x100") hit the same
issue through optional regex groups. Parse dimensions only when
present, matching the existing guard in Image parseDOM, and correct the
video getAttrs element type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Normalize non-numeric video dimensions, avoid serializing nullxnull

Review feedback: parseInt could still produce NaN when the attribute
exists but is not numeric (e.g. width="auto"), and toMarkdown wrote
null dimensions as "nullxnull". Parse dimensions through a helper that
normalizes non-finite values to null, and serialize nullish dimensions
as empty strings, which still round-trips as a video node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:29:29 -04:00
Tom Moor b1bf7c488b chore: Drop dead collaborativeEditing column from teams (#12669)
The collaborativeEditing toggle has been unused since collaborative
editing became always-on. The column is no longer defined in the Team
model nor referenced anywhere in the codebase, so this drops it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:08 -04:00
Tom Moor 9811ab6aea feat: Emoji reaction shorthand (#12650)
* Add "+:emoji:" reaction shorthand to comment form

Typing a comment that consists solely of a leading "+" followed by a
single emoji now adds that emoji as a reaction to the comment above,
instead of posting a new reply — mirroring the Slack shorthand.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Move parseReactionShorthand into editor/lib/emoji

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Open emoji menu when colon is preceded by a plus

The suggestion menu's trigger boundary excluded "+", so typing "+:" never
opened the emoji menu — preventing the "+:emoji:" reaction shorthand from
being typed. Add a configurable `precededBy` option to the Suggestion
extension and set it to "+" for the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Always allow "+" before suggestion trigger

Simplify by adding "+" to the trigger boundary for all suggestion menus
rather than making it a per-menu option. This lets the "+:emoji:" reaction
shorthand open the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 21:51:11 -04:00
Tom Moor f0899f614b fix: Improve markdown serialization speed (#12667) 2026-06-11 21:50:47 -04:00
Tom Moor c65b020655 fix: Reject collections.update requests that include both description and data (#12648) 2026-06-11 21:30:47 -04:00
Tom Moor 9791ff1170 fix: Prevent selecting word-joiner characters around multiplayer cursor (#12660)
* Possible fix for word-joiner characters copied on Chrome+Windows

* simplify
2026-06-11 09:04:38 -04:00
dependabot[bot] a25f334bb1 chore(deps): bump shell-quote from 1.8.3 to 1.8.4 (#12659)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.3 to 1.8.4.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.8.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.8.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 17:50:41 -04:00
Tom Moor e1b2993bca Reduce debounce 2026-06-09 23:01:33 -04:00
Tom Moor b3d4563730 perf: Improve performance of in-page search (#12649)
* Fix editor find freezing on long documents

In-document search (Ctrl+F) blocked the UI for several seconds while
typing in long documents. Two compounding causes:

- The find command ran a full-document search and highlight rebuild on
  every keystroke. Debounce it so typing stays responsive; the input
  value still updates immediately and pending searches are flushed when
  navigating between matches.

- search() de-duplicated matches with an O(n) scan of all prior results
  per match, making a common term that matches many times quadratic.
  Track seen positions in a Set for constant-time lookups.

* Skip redundant search highlight rebuilds, lower debounce to 100ms

The highlight plugin rebuilt every match's DOM range via domAtPos on
every editor view update while a search was active, forcing synchronous
layout on cursor moves, selection changes, and collaboration cursors.

Track the built ranges and, when the result set is unchanged, only
rebuild when they are actually stale — a referenced node has detached or
some matches were not yet resolved to ranges. isConnected checks are
cheap property reads with no layout, versus domAtPos which forces
reflow, so this is strictly less work than before and skips entirely in
the common case where all matches are resolved and connected.

Also lower the find debounce from 250ms to 100ms for snappier feedback.

* Shorten highlight rebuild comment

* PR feedback

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-09 22:46:59 -04:00
Tom Moor 7106263f88 fix: Dragging images in editor (#12647)
* fix: Editor image dragging

* feedback
2026-06-09 22:27:42 -04:00
Tom Moor 3c2e9a9723 fix: Default collections created through MCP to private (#12644) 2026-06-09 08:38:34 -04:00
Tom Moor bd01a62fc1 feat: MCP template support (#12639) 2026-06-09 07:43:21 -04:00
Tom Moor 95106e695f fix: Pasted content sometimes appears in plaintext (#12638)
* fix: 'Stuck' shift key forces plaintext paste
fix: Link on image does not survive copy/paste

* sanitize
2026-06-09 07:38:36 -04:00
Tom Moor 39623b90bd fix: Search prop is optional 2026-06-08 22:30:19 -04:00
Tom Moor a3fcd71582 fix: Widen validated emails for Azure (#12637) 2026-06-08 20:20:52 -04:00
Tom Moor a2f9962958 fix: Update validateUrlNotPrivate to match implementation in SSRF (#12636) 2026-06-08 19:35:46 -04:00
Tom Moor 709184ae0b fix: Before/After creation options appear in menu when no permission on parent doc (#12629)
* fix: Before/After creation options appear in menu when no permission on parent

closes #12624

* fix: Manager of root document sees non-functional Before/After create options

Before/After only gated on the team-level createDocument ability, so a user
with document-level manager access (but no collection access) saw the options
yet hit a backend rejection. Gate on the actual sibling location instead,
mirroring authorizeDocumentCreate.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:44:09 -04:00
dependabot[bot] bc5ffb79b2 chore(deps): bump react-day-picker from 8.10.1 to 8.10.2 (#12632)
Bumps [react-day-picker](https://github.com/gpbl/react-day-picker/tree/HEAD/packages/react-day-picker) from 8.10.1 to 8.10.2.
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Changelog](https://github.com/gpbl/react-day-picker/blob/main/packages/react-day-picker/CHANGELOG.md)
- [Commits](https://github.com/gpbl/react-day-picker/commits/v8.10.2/packages/react-day-picker)

---
updated-dependencies:
- dependency-name: react-day-picker
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:43:55 -04:00
dependabot[bot] d703f8acf3 chore(deps-dev): bump @vitest/ui from 4.1.6 to 4.1.8 (#12631)
Bumps [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) from 4.1.6 to 4.1.8.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/ui)

---
updated-dependencies:
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:43:40 -04:00
dependabot[bot] 1c23cbec1b chore(deps): bump @simplewebauthn/server from 13.3.0 to 13.3.1 (#12633)
Bumps [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) from 13.3.0 to 13.3.1.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.1/packages/server)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:43:16 -04:00
dependabot[bot] 1caeafaeed chore(deps-dev): bump discord-api-types from 0.38.46 to 0.38.48 (#12634)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.38.46 to 0.38.48.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.38.46...0.38.48)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:42:59 -04:00
dependabot[bot] 969a7bb97d chore(deps-dev): bump prettier from 3.7.4 to 3.8.3 (#12635)
Bumps [prettier](https://github.com/prettier/prettier) from 3.7.4 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.4...3.8.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:42:38 -04:00
Tom Moor 053693b9d5 fix: Spurious post-import edits (#12620) 2026-06-07 21:13:23 -04:00
Tom Moor 7938ffdd7a Restore SidebarButton position default to fix top padding regression (#12618)
The position prop is omitted at several call sites (App, Settings,
Shared sidebars) and relied on the top default for inset-titlebar
padding. Make the prop optional and restore the default so the lint
rule stays satisfied without changing runtime behavior.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 17:24:45 -04:00
Tom Moor 9b8acf3efb Remove unnecessary default parameter values from function signatures (#12617)
* Fix remaining no-useless-default-assignment lint warnings

* Promote no-useless-default-assignment lint rule to error
2026-06-07 15:46:01 -04:00
Tom Moor ac6b680cdb fix: notice query string consumed on unauthenticated routes 2026-06-07 14:22:26 -04:00
Tom Moor 27c633eb8b fix: Code blocks auto-collapse while editing (#12616) 2026-06-07 13:29:41 -04:00
Tom Moor ca36451e42 Improve handling of non-HD Google logins from root domain (#12615) 2026-06-07 13:16:25 -04:00
Tom Moor 0d198294eb fix: Improve patch merging of links in table cells (#12614)
* fix: Improve merging of links in table cells through patch

* PR feedback
2026-06-07 12:09:44 -04:00
Tom Moor bc63aba1d1 fix: place cursor at start of inserted table row/column (#12610)
* fix: place cursor at start of inserted table row/column

When using Insert before for a table row or column, the selection was
collapsed onto the mapped previous selection — landing at the bottom of the
shifted neighbouring column rather than in the newly inserted cell. Move the
cursor to the start of the first cell of the inserted row/column instead.

* feat: Inline editor menu (#12611)

* wip

* Mobile support

* Address review feedback on inline menu

- Mark selection-restore transaction as not added to history
- Only open desktop inline menu when an anchor is available

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: place cursor at start of inserted table row/column

When using Insert before for a table row or column, the selection was
collapsed onto the mapped previous selection — landing at the bottom of the
shifted neighbouring column rather than in the newly inserted cell. Move the
cursor to the start of the first cell of the inserted row/column instead.

* Add handling for After variants
Add lint rule

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 11:58:48 -04:00
Tom Moor ea665b80ee feat: Inline editor menu (#12611)
* wip

* Mobile support

* Address review feedback on inline menu

- Mark selection-restore transaction as not added to history
- Only open desktop inline menu when an anchor is available

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:57:34 -04:00
Tom Moor 492af6683b Add document restore functionality to MCP tools (#12575)
* Add restore_document MCP tool and archived/trashed listing

Closes the delete/restore asymmetry in the MCP server: previously documents
could be archived or trashed via delete_document but never recovered.

- Add restore_document tool to recover archived or trashed documents,
  optionally into a different collection.
- Add a status option ("archived" | "trashed") to list_documents so agents
  can discover what to restore.
- Extract the documents.restore route logic into a shared documentRestorer
  command, used by both the REST endpoint and the MCP tool.

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

* Use type-only import for Document in documentRestorer

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

* Revert archived/trashed status option on list_documents

Keeps the restore_document tool and shared documentRestorer command;
removes the list_documents status filter and its tests.

https://claude.ai/code/session_01HpFcYtgEZJ96iaFMuGGCmc

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-06 15:08:52 -04:00
Tom Moor f4b80d5301 fix: PDF display does not correctly scale on ombile (#12608) 2026-06-06 10:14:35 -04:00
Tom Moor 58f0613b5f Delete .github/workflows/docker-build-check.yml 2026-06-06 09:30:50 -04:00
Tom Moor c2edd41e87 Enable manual runs of 'Publish build' workflow 2026-06-06 09:05:01 -04:00
Tom Moor 62c0f15c06 Switch from ubicloud -> blacksmith (#12606) 2026-06-06 09:02:43 -04:00
Tom Moor c75815b90c v1.8.1 2026-06-06 08:46:07 -04:00
Translate-O-Tron 33e3680782 New Crowdin updates (#12464)
* fix: New Czech translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

* fix: New Romanian 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 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 Indonesian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Romanian 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 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 Indonesian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Romanian 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 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 Indonesian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Romanian 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 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 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]

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

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

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

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

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

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

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

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

* fix: New Romanian 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 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 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-06-06 08:43:35 -04:00
Tom Moor b23a39bd39 Add email verification check during sign-in flow (#12605)
* Add email verification check during sign-in flow

* Add support for Entra External ID with OIDC standard verification claim
2026-06-06 08:01:26 -04:00
Tom Moor f329b56d0e fix: Hard break serialization for commonMark (#12603)
* fix: Hard break serialization for commonMark

* tests
2026-06-06 07:24:16 -04:00
Tom Moor be3f28afea fix: Remove parsing rules when not supported in editor (#12604)
* fix: Remove table markdown parsing rule when not supported in editor

* Additional nodes, tests
2026-06-05 23:42:40 -04:00
Tom Moor 997d38a6ac fix: document.collaboratorIds iterable error (#12602) 2026-06-05 23:16:45 -04:00
Tom Moor b5465376ae fix: Mermaid persisted as last used language (#12601)
* fix: Mermaid persisted as last used language

* Suppress mermaid in getFrequentCodeLanguages too
2026-06-05 22:33:03 -04:00
Tom Moor 9ec6b8309d chore: Improve handling of 'expected' network errors from webhooks (#12599) 2026-06-05 18:00:37 -04:00
Tom Moor aad2483ff9 fix: Search term highlights missing (#12598) 2026-06-05 21:57:09 +00:00
Tom Moor 0c0facc2a1 perf: Avoid empty webhook processor work via cached subscription lookup (#12593)
* Avoid empty webhook processor work via cached subscription lookup

WebhookProcessor ran for every event but most teams have no matching
webhook subscription, costing an empty processor job and a database query
per event.

Cache a team's enabled subscriptions ({ id, events }) in Redis, invalidated
by model lifecycle hooks, and add an optional BaseProcessor.shouldQueue hook
consulted by the global event queue so the webhook processor only enqueues a
job when a matching subscription exists.

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

* feedback

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:53:40 -04:00
Tom Moor e864684d56 fix: Private IP lookup should be invalid request rather than internal error (#12592) 2026-06-05 09:19:05 -04:00
Tom Moor 88de417a21 Refactor drag-and-drop to support dragging from document lists (#12587)
* Allow dragging documents from list views into the sidebar

Previously the react-dnd provider was scoped to the sidebar, so only
sidebar rows could be dragged. This lifts the DndProvider up to the
authenticated layout so both the main content and the sidebar share a
single drag-and-drop context, and makes DocumentListItem a drag source.

Now any document in search results or paginated lists (Home, Drafts,
Collections, etc.) can be dragged into the sidebar to move it between
collections, reparent it under another document, star it, or archive it
— reusing the existing sidebar drop targets.

* Make the whole Starred section a drop target to star documents

Previously the only "create star" drop targets in the Starred section
were the thin cursors between items, so dragging a document onto the
section header or a starred row showed the drop cursor but did nothing.

Wrap the section in a catch-all drop target (mirroring the Archive
section) so dropping anywhere in Starred stars the document, while the
precise inter-item cursors still control ordering. A didDrop guard on
useDropToCreateStar prevents the catch-all from double-starring when a
nested cursor already handled the drop, and the hover highlight uses a
shallow isOver check so it only lights up when not over a nested target.

* Let document list drag ghost follow the cursor

The sidebar drag placeholder tethers the ghost near its starting x so it
stays aligned with the sidebar during reordering. When a drag starts out
in the main content (a document list item), that clamp pinned the ghost
to a narrow band, making it look stuck in a small area.

Thread a constrainToSidebar flag through the drag item (true for sidebar
drags, false for document list drags) and let the placeholder follow the
cursor freely when the drag originated outside the sidebar.

* Clarify constrainToSidebar JSDoc to match placeholder behavior

The placeholder treats an unset flag as tethered (constrainToSidebar
!== false), so external drags must set it explicitly to false rather
than leaving it unset. Update the comment to reflect that.

* css
2026-06-05 08:27:10 -04:00
Tom Moor 985038525c fix: Sticky table header styling in Safari (#12590)
* fix: Sticky table header styling in Safari

* feedback
2026-06-05 07:42:26 -04:00
Tom Moor c808bed712 Add mobile drawer UI for FilterOptions component (#12576)
* Render filter options as drawer popover on mobile

Filter options on the search page (and other FilterOptions consumers)
previously rendered as a Radix dropdown on all viewports. On mobile this
now renders as a bottom-sheet Drawer, matching the popover style already
used by context menus.

https://claude.ai/code/session_01MSjTD67PWfGbwgNA5FFoSH

* Fix filter drawer search input overlapping first option on mobile

The Input wrapper uses flex: 0 (a 0% basis), which collapsed the search
input inside the drawer's flex column so its content painted over the
first list item. Use flex: none to retain the input's natural height.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 07:42:15 -04:00
Tom Moor bfddf4bb4c fix: Unhandled server error in MCP route (#12586) 2026-06-04 23:31:16 -04:00
Tom Moor 1cc10f5fff fix: Increase valid user-supplied URL length to 1024 (#12585)
* fix: Increase valid user-supplied URL length to 1024

* fix: Wrap URL length migration in a transaction

Wrap the multi-column changeColumn operations in a transaction so a
failure on any column rolls back the whole migration rather than leaving
the database partially migrated.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:30:55 -04:00
dependabot[bot] ce3d710888 chore(deps): bump hono from 4.12.18 to 4.12.23 (#12584)
Bumps [hono](https://github.com/honojs/hono) from 4.12.18 to 4.12.23.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.18...v4.12.23)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 17:33:06 -04:00
Tom Moor 2aff3907c5 Make collection title and icon inline editable like documents (#12574)
Collection title/icon editing was gated by `isEditRoute && separateEditMode`,
which meant that in the default inline editing mode (separateEditMode off) the
title and icon were never editable inline — even though the collection
description was. This diverged from documents and from the collection
description editor.

Align the Header editing gate with documents (DataLoader) and the Overview
description editor: `isEditRoute || !separateEditMode`, so title and icon are
seamlessly editable inline whenever the user has update permission.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 09:14:04 -04:00
Tom Moor 02c5c93bd8 Account for screen safe area in mobile drawer bottom padding (#12577)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 09:13:50 -04:00
Tom Moor e9e565dc2b fix: Access request logic for collection managers (#12579)
* fix: Access request logic for collection managers

* test: Exercise collection-manager path in access request regression tests

Grant the non-workspace-admin manager a collection-level Admin membership
instead of a direct document-level membership, so authorization flows
through the collection-manager path being tested for #12567.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:24:51 -04:00
Tom Moor 4126b94f7c fix: Add gap between search input and New doc button on mobile (#12578)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 23:52:18 -04:00
Tom Moor a21ddc4999 Add tagging of outgoing emails (#12570)
* Add tagging of outgoing emails

* Detect SES configured via well-known service key

The isSES check only matched "amazonaws" in the host, so SES configured
through SMTP_SERVICE (e.g. "SES" or "SES-US-EAST-1") was not detected and
tagging headers were not applied.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:51:09 -04:00
Tom Moor 0d50f0d60a fix: Do not close icon picker on choice (#12573) 2026-06-03 23:24:32 -04:00
Tom Moor c419c3ab63 Add admin interface to change user avatars (#12405) 2026-06-03 07:32:11 -04:00
Tom Moor 8464d99589 fix: Intermitent document highlighted (#12566)
closes #12554
2026-06-03 07:05:28 -04:00
dependabot[bot] a20c8e5371 chore(deps): bump zod from 4.3.6 to 4.4.3 (#12563)
* chore(deps): bump zod from 4.3.6 to 4.4.3

Bumps [zod](https://github.com/colinhacks/zod) from 4.3.6 to 4.4.3.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.3)

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

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

* fix: Make files.create file param optional in schema for zod 4.4

zod 4.4 changed z.custom() to reject undefined. Since validate runs
before multipart injects the file, validation failed with 400 on all
files.create requests. Mark the field optional and guard in the handler.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:34:41 -04:00
Tom Moor 3a442ec5d3 closes #12552 (#12565) 2026-06-02 22:11:24 -04:00
dependabot[bot] e32b3772b2 chore(deps-dev): bump vite-plugin-babel from 1.6.0 to 1.7.3 (#12561)
* chore(deps-dev): bump vite-plugin-babel from 1.6.0 to 1.7.3

Bumps [vite-plugin-babel](https://github.com/owlsdepartment/vite-plugin-babel) from 1.6.0 to 1.7.3.
- [Commits](https://github.com/owlsdepartment/vite-plugin-babel/commits)

---
updated-dependencies:
- dependency-name: vite-plugin-babel
  dependency-version: 1.7.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

* fix: Use include option for vite-plugin-babel TS transform

vite-plugin-babel 1.7.0 added an `include` option defaulting to
`/\.jsx?$/` (JS only) that is applied before `filter`, so .ts/.tsx
files were no longer transformed by Babel and reached the parser
with types intact. Switch to the `include` option to match TS files.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:11:03 -04:00
dependabot[bot] b2c66c5190 chore(deps-dev): bump the babel group with 7 updates (#12560)
Bumps the babel group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) | `7.28.6` | `7.29.7` |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.29.0` | `7.29.7` |
| [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) | `7.29.0` | `7.29.7` |
| [@babel/plugin-transform-class-properties](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-class-properties) | `7.28.6` | `7.29.7` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.29.5` | `7.29.7` |
| [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) | `7.28.5` | `7.29.7` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.28.5` | `7.29.7` |


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

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

Updates `@babel/plugin-proposal-decorators` from 7.29.0 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-plugin-proposal-decorators)

Updates `@babel/plugin-transform-class-properties` from 7.28.6 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-plugin-transform-class-properties)

Updates `@babel/preset-env` from 7.29.5 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-preset-env)

Updates `@babel/preset-react` from 7.28.5 to 7.29.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-preset-react)

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

---
updated-dependencies:
- dependency-name: "@babel/cli"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/core"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-class-properties"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-react"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-version: 7.29.7
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 22:01:45 -04:00
dependabot[bot] 875ba8d03c chore(deps): bump yjs from 13.6.30 to 13.6.31 (#12562)
Bumps [yjs](https://github.com/yjs/yjs) from 13.6.30 to 13.6.31.
- [Release notes](https://github.com/yjs/yjs/releases)
- [Commits](https://github.com/yjs/yjs/compare/v13.6.30...v13.6.31)

---
updated-dependencies:
- dependency-name: yjs
  dependency-version: 13.6.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 22:01:30 -04:00
dependabot[bot] bcf1155818 chore(deps): bump semver from 7.7.4 to 7.8.1 (#12564)
Bumps [semver](https://github.com/npm/node-semver) from 7.7.4 to 7.8.1.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.7.4...v7.8.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 22:01:03 -04:00
Tom Moor 7e252f0892 fix: Add missing safeEqual to notification unsubscribe endpoints (#12551) 2026-06-01 22:07:09 -04:00
Tom Moor b2309df76d v1.8.0 2026-06-01 08:06:02 -04:00
Tom Moor 608a68b010 fix: Missing text color on search highlight (#12547) 2026-05-31 22:13:52 -04:00
Tom Moor 991df631ca Trigger hover previews when editor has focus (#12545)
* fix: Trigger hover previews when editor has focus
2026-05-31 16:29:56 -04:00
Tom Moor ad89288eac fix: Resolve uuid to ^11.1.1 to patch CVE-2026-41907 (#12541)
Forces transitive uuid copies (8.3.2 via sequelize/bull, 9.0.1 via
@hocuspocus/*) onto the patched 11.1.1, addressing GHSA-w5hq-g745-h8pq.
11.1.1 is the highest version that is both patched and ships a CommonJS
build, which the require()-based consumers depend on.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:26:09 -04:00
Apoorv Mishra b2bb2335a1 Words separated by hyphens to be treated as a single unit for word diffing (#11272)
* fix: hyphenated word diff

* Add tests, simplify, reduce gap allowance

* tsc

* simplify

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2026-05-30 18:11:14 -04:00
Tom Moor 224230eaa0 perf: Remove N+1 query in documents.search (#12540) 2026-05-30 18:11:00 -04:00
Tom Moor d0ede882c6 perf: More memory improvements (#12539)
* perf: Lazy import mailparser, @fast-csv, and franc deps

Moves heavy dependencies off the startup path into the narrow async code
paths that actually use them, mirroring the mammoth lazy-import change:

- mailparser: only needed for Confluence Word imports (confluenceToHtml)
- @fast-csv/parse: only needed for CSV imports (csvToMarkdown)
- franc / iso-639-3: only needed by the DocumentUpdateText worker task

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

* perf: Lazy import jsdom dep

jsdom is one of the heaviest server dependencies but is only needed for
HTML export (ProsemirrorHelper.toHTML) and HTML import
(DocumentConverter.htmlToProsemirror). Move it to a lazy `await import`
inside those methods so its dependency tree stays off the startup path.

Both methods become async; all callers were already in async contexts.
The type-only usage in patchGlobalEnv is now an `import type`.
2026-05-30 17:31:04 -04:00
Tom Moor b189c308e5 perf: Avoid loading unused services (#12537)
* fix: Run single process when only the worker service is enabled

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

* perf: Improve memory consumption through lazy service loading

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:48:31 -04:00
Tom Moor cecc9ef576 perf: Lazy import Mammoth dep (#12538) 2026-05-30 16:48:19 -04:00
Tom Moor 553daed606 fix: Mermaid diagrams mis-sized on high-DPI/RDP displays (#11782) (#12531)
Re-frame the rendered SVG viewBox from a getBBox() measurement taken in
the visible editor rather than the hidden render element, where the
measurement is unreliable on high-DPI/RDP sessions. Bump the cache
namespace so previously mis-sized diagrams are re-rendered.
2026-05-30 08:20:46 -04:00
Tom Moor 5c991bbd5f fix: Toggle block within collapsed heading display (#12536) 2026-05-30 08:06:50 -04:00
Tom Moor 334b179048 fix: Prevent Linear unfurl errors from bubbling to error tracking (#12532)
Returning the unfurl promises without awaiting them inside the try
block meant rejections (e.g. "Entity not found: Issue") escaped the
catch and were reported to error tracking. Await them so they are
caught and returned as a handled { error } result.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 07:59:05 -04:00
208 changed files with 7878 additions and 2073 deletions
+5
View File
@@ -140,6 +140,11 @@ FORCE_HTTPS=true
# and "X-Client-IP".
# PROXY_IP_HEADER=
# Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
# X-Forwarded-Proto) set by an upstream proxy. Set to false if not
# running behind a proxy in production.
# PROXY_HEADERS_TRUSTED=true
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– AUTHENTICATION ––––––––––
@@ -43,7 +43,7 @@ jobs:
uses: actions/checkout@v5
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@main
uses: calibreapp/image-actions@3d5873ac3e7bf1a38b24d9778d8dc639d5706d8b # main
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
@@ -54,7 +54,7 @@ jobs:
if: |
github.event_name != 'pull_request' &&
steps.calibre.outputs.markdown != ''
uses: peter-evans/create-pull-request@v3
uses: peter-evans/create-pull-request@18f7dc018cc2cd597073088f7c7591b9d1c02672 # v3
with:
title: "chore: Auto Compress Images"
branch-suffix: timestamp
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
deps: ${{ steps.filter.outputs.deps }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2
id: filter
with:
filters: |
@@ -126,7 +126,7 @@ jobs:
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
uses: relative-ci/agent-action@38328454d6a23942175eba485fca4fbb807b1f03 # v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
-43
View File
@@ -1,43 +0,0 @@
name: Docker Build Check
on:
push:
paths:
- "Dockerfile"
- "Dockerfile.base"
pull_request:
paths:
- "Dockerfile"
- "Dockerfile.base"
env:
BASE_IMAGE_NAME: outline-base
jobs:
build:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build base image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
tags: ${{ env.BASE_IMAGE_NAME }}:latest
push: false
- name: Build main image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
+14 -21
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
env:
IMAGE_NAME: outlinewiki/outline
@@ -11,13 +12,13 @@ env:
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker base meta
id: base_meta
@@ -37,7 +38,7 @@ jobs:
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v7
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile.base
@@ -45,8 +46,6 @@ jobs:
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
@@ -61,7 +60,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v7
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile
@@ -69,8 +68,6 @@ jobs:
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
@@ -90,13 +87,13 @@ jobs:
retention-days: 1
build-amd:
runs-on: ubicloud-standard-8
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker base meta
id: base_meta
@@ -116,7 +113,7 @@ jobs:
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v7
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile.base
@@ -124,8 +121,6 @@ jobs:
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
@@ -140,7 +135,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v7
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile
@@ -148,8 +143,6 @@ jobs:
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
@@ -169,7 +162,7 @@ jobs:
retention-days: 1
merge:
runs-on: ubicloud-standard-8
runs-on: blacksmith-8vcpu-ubuntu-2404
needs:
- build-amd
- build-arm
@@ -188,8 +181,8 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker meta
id: meta
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
- name: Create pull request
if: steps.check.outputs.updated == 'true'
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
+18
View File
@@ -73,6 +73,23 @@
"eqeqeq": "error",
"curly": "error",
"no-console": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "prosemirror-tables",
"importNames": [
"addRowBefore",
"addRowAfter",
"addColumnBefore",
"addColumnAfter"
],
"message": "Use the wrappers from shared/editor/commands/table instead, which respect the target index and place the cursor in the inserted cell."
}
]
}
],
"no-unused-expressions": "error",
"arrow-body-style": ["error", "as-needed"],
"react/react-in-jsx-scope": "off",
@@ -93,6 +110,7 @@
"typescript/consistent-type-imports": "error",
"typescript/restrict-template-expressions": "error",
"typescript/no-floating-promises": "error",
"typescript/no-useless-default-assignment": "error",
"no-unused-vars": [
"error",
{
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.7.1
Licensed Work: Outline 1.8.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-05-04
Change Date: 2030-06-06
Change License: Apache License, Version 2.0
+22 -2
View File
@@ -240,6 +240,26 @@ function findDocumentSiblingIndex(
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
}
/**
* Determines whether the user can create a sibling of the given document.
* A sibling shares the document's parent, so this mirrors the backend's
* create authorization: create permission on the parent document, or on the
* collection when the document is at the root.
*
* @param stores - the root stores.
* @param document - the document to create a sibling of.
* @returns true if the user can create a sibling.
*/
function canCreateSiblingDocument(
stores: ActionContext["stores"],
document: { collectionId?: string | null; parentDocumentId?: string }
): boolean {
return document.parentDocumentId
? stores.policies.abilities(document.parentDocumentId).createChildDocument
: !!document.collectionId &&
stores.policies.abilities(document.collectionId).createDocument;
}
export const createNestedDocument = createInternalLinkAction({
name: ({ t }) => t("Nested document"),
analyticsName: "New document",
@@ -279,7 +299,7 @@ const createDocumentBefore = createInternalLinkAction({
if (collection?.sort.field === "title") {
return false;
}
return stores.policies.abilities(currentTeamId).createDocument;
return canCreateSiblingDocument(stores, document);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
@@ -321,7 +341,7 @@ const createDocumentAfter = createInternalLinkAction({
if (collection?.sort.field === "title") {
return false;
}
return stores.policies.abilities(currentTeamId).createDocument;
return canCreateSiblingDocument(stores, document);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
+4
View File
@@ -13,6 +13,10 @@ ActiveCollectionSection.priority = 0.8;
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DateSection = ({ t }: ActionContext) => t("Date");
DateSection.priority = 1;
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SearchResultsSection = ({ t }: ActionContext) =>
+12 -8
View File
@@ -1,6 +1,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { useLocation } from "react-router-dom";
import { EditorAwareHTML5Backend } from "~/components/EditorAwareHTML5Backend";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
@@ -104,14 +106,16 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<DocumentContextProvider>
<RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
<DndProvider backend={EditorAwareHTML5Backend}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</DndProvider>
</PortalContext.Provider>
</RightSidebarProvider>
</DocumentContextProvider>
+2 -2
View File
@@ -79,10 +79,10 @@ function Collaborators(props: Props) {
// Memoize ids to avoid unnecessary effect executions
const missingUserIds = useMemo(
() =>
uniq([...document.collaboratorIds, ...Array.from(presentIds)])
uniq([...collaboratorIdsSet, ...presentIds])
.filter((userId) => !users.get(userId))
.sort(),
[document.collaboratorIds, presentIds, users]
[collaboratorIdsSet, presentIds, users]
);
useEffect(() => {
+1 -1
View File
@@ -15,7 +15,7 @@ export default function DesktopEventHandler() {
const hasDisabledUpdateMessage = useRef(false);
useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => {
Desktop.bridge?.redirect((path: string, replace: boolean) => {
if (replace) {
history.replace(path);
} else {
+24 -1
View File
@@ -5,6 +5,7 @@ import {
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { Link } from "react-router-dom";
import { DocumentIcon } from "outline-icons";
import styled, { css, useTheme } from "styled-components";
@@ -27,6 +28,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
import { useDragDocument } from "./Sidebar/hooks/useDragAndDrop";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
@@ -98,6 +100,23 @@ function DocumentListItem(
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
const [{ isDragging }, draggableRef] = useDragDocument(
document.asNavigationNode,
0,
document,
false,
false
);
const mergedRef = React.useMemo(
() =>
mergeRefs<HTMLAnchorElement>([
itemRef,
draggableRef,
] as React.Ref<HTMLAnchorElement>[]),
[itemRef, draggableRef]
);
return (
<ActionContextProvider
value={{
@@ -114,9 +133,10 @@ function DocumentListItem(
onClose={handleMenuClose}
>
<DocumentLink
ref={itemRef}
ref={mergedRef}
dir={document.dir}
$isStarred={document.isStarred}
$isDragging={isDragging}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
@@ -227,6 +247,7 @@ const Actions = styled(EventBoundary)`
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$isDragging?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
@@ -237,6 +258,8 @@ const DocumentLink = styled(Link)<{
max-height: 50vh;
width: calc(100vw - 8px);
cursor: var(--pointer);
transition: opacity 250ms ease;
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
&:focus-visible {
outline: none;
+63
View File
@@ -0,0 +1,63 @@
import type { BackendFactory } from "dnd-core";
import { HTML5Backend } from "react-dnd-html5-backend";
/**
* react-dnd's HTML5 backend installs global capture-phase listeners on `window`
* that call `preventDefault()` on drops whose dataTransfer resembles a native
* item including a dragged `<img>`, which is how ProseMirror serializes an
* image drag.
*
* These handlers run before ProseMirror's, and they live on `window`, so a
* propagation-based guard can't stop react-dnd without also starving the editor
* of the event. Instead we wrap the backend and make its top-level capture
* handlers no-op for events that occur within the editor surface.
*/
const captureHandlerNames = [
"handleTopDragStartCapture",
"handleTopDragEnterCapture",
"handleTopDragOverCapture",
"handleTopDragLeaveCapture",
"handleTopDropCapture",
"handleTopDragEndCapture",
] as const;
const isWithinEditor = (target: EventTarget | null): boolean =>
target instanceof Element && Boolean(target.closest(".ProseMirror"));
/**
* An HTML5 drag-and-drop backend that ignores drag events originating within the
* rich text editor so that ProseMirror can handle them itself.
*
* @param manager The drag-and-drop manager.
* @param context The global context.
* @param options Backend options.
* @returns The wrapped HTML5 backend instance.
*/
export const EditorAwareHTML5Backend: BackendFactory = (
manager,
context,
options
) => {
const backend = HTML5Backend(manager, context, options);
// The capture handlers are private instance fields on the backend, so reach
// for them through an index signature view of the instance.
const handlers = backend as unknown as Record<
string,
(event: DragEvent) => void
>;
for (const name of captureHandlerNames) {
const original = handlers[name];
if (typeof original === "function") {
handlers[name] = (event: DragEvent) => {
if (isWithinEditor(event.target)) {
return;
}
original.call(backend, event);
};
}
}
return backend;
};
+123 -40
View File
@@ -1,16 +1,26 @@
import { deburr } from "es-toolkit/compat";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { FetchPageParams } from "~/stores/base/Store";
import Button, { Inner } from "~/components/Button";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Input, { NativeInput, Outline } from "./Input";
import type { PaginatedItem } from "./PaginatedList";
import PaginatedList from "./PaginatedList";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import { MenuProvider } from "./primitives/Menu/MenuContext";
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
import * as MenuComponents from "./primitives/components/Menu";
import { MenuIconWrapper } from "./primitives/components/Menu";
interface TFilterOption extends PaginatedItem {
@@ -34,7 +44,7 @@ type Props = {
const FilterOptions = ({
options,
selectedKeys = [],
selectedKeys,
className,
onSelect,
showFilter,
@@ -45,6 +55,7 @@ const FilterOptions = ({
...rest
}: Props) => {
const { t } = useTranslation();
const isMobile = useMobile();
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const [open, setOpen] = React.useState(false);
@@ -58,23 +69,45 @@ const FilterOptions = ({
: "";
const renderItem = React.useCallback(
(option) => (
<MenuButton
key={option.key}
icon={
option.icon && showIcons ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined
}
label={option.label}
onClick={() => {
onSelect(option.key);
setOpen(false);
}}
selected={selectedKeys.includes(option.key)}
/>
),
[onSelect, showIcons, selectedKeys]
(option) => {
const handleClick = () => {
onSelect(option.key);
setOpen(false);
};
const icon =
option.icon && showIcons ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined;
// On mobile the options render inside a Drawer (bottom sheet) rather than
// a Radix dropdown menu, so use the raw menu components directly instead
// of the dropdown-bound MenuButton which expects a menu root context.
if (isMobile) {
return (
<MenuComponents.MenuButton key={option.key} onClick={handleClick}>
{icon}
<MenuComponents.MenuLabel>{option.label}</MenuComponents.MenuLabel>
<MenuComponents.SelectedIconWrapper aria-hidden>
{selectedKeys.includes(option.key) ? (
<CheckmarkIcon size={18} />
) : null}
</MenuComponents.SelectedIconWrapper>
</MenuComponents.MenuButton>
);
}
return (
<MenuButton
key={option.key}
icon={icon}
label={option.label}
onClick={handleClick}
selected={selectedKeys.includes(option.key)}
/>
);
},
[onSelect, showIcons, selectedKeys, isMobile]
);
const handleFilter = React.useCallback(
@@ -169,39 +202,73 @@ const FilterOptions = ({
React.useEffect(() => {
if (open) {
searchInputRef.current?.focus();
// Avoid auto-focusing on mobile as it immediately pops the on-screen
// keyboard over the drawer.
if (!isMobile) {
searchInputRef.current?.focus();
}
} else {
setQuery("");
}
}, [open]);
}, [open, isMobile]);
const showFilterInput = showFilter || options.length > 10;
const defaultLabel = rest.defaultLabel || t("Filter options");
const trigger = (
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
);
const list = (
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput && !isMobile ? <Spacer /> : undefined}
empty={<Empty />}
/>
);
// On mobile render the options inside a Drawer (bottom sheet) to match the
// popover style used by context menus across the app.
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent aria-label={defaultLabel} aria-describedby={undefined}>
<DrawerTitle>{defaultLabel}</DrawerTitle>
{showFilterInput && (
<MobileSearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
margin={0}
/>
)}
<StyledScrollable hiddenScrollbars>{list}</StyledScrollable>
</DrawerContent>
</Drawer>
);
}
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={setOpen}>
<MenuTrigger>
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
</MenuTrigger>
<MenuTrigger>{trigger}</MenuTrigger>
<MenuContent aria-label={defaultLabel} align="start">
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
{list}
{showFilterInput && (
<SearchInput
ref={searchInputRef}
@@ -260,6 +327,22 @@ const SearchInput = styled(Input)`
}
`;
const MobileSearchInput = styled(Input)`
/* "none" keeps an auto basis so the input retains its natural height; a
flexible/0% basis would collapse it and overlap the list below. */
flex: none;
margin: 0 6px 6px;
${NativeInput} {
/* 16px avoids iOS zooming the viewport when the input is focused. */
font-size: 16px;
}
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
-1
View File
@@ -107,7 +107,6 @@ const IconPicker = ({
const handleIconChange = React.useCallback(
(ic: string) => {
setOpen(false);
const icType = determineIconType(ic);
const finalColor = icType === IconType.SVG ? chosenColor : null;
onChange(ic, finalColor);
+7
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import {
isModKey,
@@ -117,6 +118,12 @@ function InputSearchPage({
const InputMaxWidth = styled(Input).attrs({ round: true })`
max-width: min(calc(30vw + 20px), 100%);
/* On mobile the input grows to fill the header, so add a gap before the
* adjacent action button (e.g. "New doc"). */
${breakpoint("mobile", "tablet")`
margin-inline-end: 8px;
`}
`;
const Shortcut = styled.span<{ $visible: boolean }>`
+8 -1
View File
@@ -33,6 +33,12 @@ type Props = {
align?: "start" | "end";
/** ARIA label for the menu */
ariaLabel: string;
/**
* Whether the menu should lock page scroll and trap focus while open.
* Defaults to true. Set to false to avoid the scrollbar-removal layout
* shift when the menu lives inside a scrollable container.
*/
modal?: boolean;
/** Additional component to display at the bottom of the top-level menu */
append?: React.ReactNode;
/** Callback when menu is opened */
@@ -50,6 +56,7 @@ export const DropdownMenu = observer(
children,
align = "start",
ariaLabel,
modal = true,
append,
onOpen,
onClose,
@@ -116,7 +123,7 @@ export const DropdownMenu = observer(
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={handleOpenChange}>
<Menu open={open} onOpenChange={handleOpenChange} modal={modal}>
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</MenuTrigger>
+70 -85
View File
@@ -1,8 +1,6 @@
import { observer } from "mobx-react";
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useEffect, useState, useCallback, useRef } from "react";
import {
DragActiveProvider,
SidebarScrollProvider,
@@ -62,15 +60,6 @@ function AppSidebar() {
}
}, [documents, collections, user.isViewer]);
const [dndArea, setDndArea] = useState();
const handleSidebarRef = useCallback((node) => setDndArea(node), []);
const html5Options = useMemo(
() => ({
rootElement: dndArea,
}),
[dndArea]
);
// Scrollable reads ref.current internally for its shadow/ResizeObserver
// logic, so we must pass an object ref — a callback ref would leave those
// reads undefined. We mirror the attached node into state so the
@@ -82,83 +71,79 @@ function AppSidebar() {
}, []);
return (
<Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}>
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
<DragActiveProvider>
<DragPlaceholder />
<Sidebar hidden={!ui.readyToShow}>
<DragActiveProvider>
<DragPlaceholder />
<TeamMenu>
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
<TeamMenu>
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
>
{isMobile ? null : (
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
>
{isMobile ? null : (
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed
? t("Expand sidebar")
: t("Collapse sidebar")
}
style={{ paddingInline: 4 }}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
)}
</SidebarButton>
</TeamMenu>
<Overflow>
<Section>
<SidebarLink
to={homePath()}
icon={<HomeIcon />}
exact={false}
label={t("Home")}
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed
? t("Expand sidebar")
: t("Collapse sidebar")
}
style={{ paddingInline: 4 }}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
<SidebarLink
to={searchPath()}
icon={<SearchIcon />}
label={t("Search")}
exact={false}
onClick={handleSearchClick}
/>
{can.createDocument && <DraftsLink />}
</Tooltip>
)}
</SidebarButton>
</TeamMenu>
<Overflow>
<Section>
<SidebarLink
to={homePath()}
icon={<HomeIcon />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={searchPath()}
icon={<SearchIcon />}
label={t("Search")}
exact={false}
onClick={handleSearchClick}
/>
{can.createDocument && <DraftsLink />}
</Section>
</Overflow>
<Scrollable flex shadow ref={scrollRef}>
<SidebarScrollProvider value={scrollArea}>
<Section>
<Starred />
</Section>
<Section>
<SharedWithMe />
</Section>
<Section>
<Collections />
</Section>
{can.createDocument && (
<Section auto>
<ArchiveLink />
</Section>
</Overflow>
<Scrollable flex shadow ref={scrollRef}>
<SidebarScrollProvider value={scrollArea}>
<Section>
<Starred />
</Section>
<Section>
<SharedWithMe />
</Section>
<Section>
<Collections />
</Section>
{can.createDocument && (
<Section auto>
<ArchiveLink />
</Section>
)}
<Section>
{can.createDocument && <TrashLink />}
<SidebarAction action={inviteUser} />
</Section>
</SidebarScrollProvider>
</Scrollable>
</DragActiveProvider>
</DndProvider>
)}
)}
<Section>
{can.createDocument && <TrashLink />}
<SidebarAction action={inviteUser} />
</Section>
</SidebarScrollProvider>
</Scrollable>
</DragActiveProvider>
<HistoryNavigation />
</Sidebar>
);
@@ -19,7 +19,8 @@ const layerStyles: React.CSSProperties = {
function getItemStyles(
initialOffset: XYCoord | null,
currentOffset: XYCoord | null,
sidebarWidth: number
sidebarWidth: number,
constrainToSidebar: boolean
) {
if (!initialOffset || !currentOffset) {
return {
@@ -27,10 +28,14 @@ function getItemStyles(
};
}
const { y } = currentOffset;
const x = Math.max(
initialOffset.x,
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
);
// Sidebar drags keep the ghost tethered near its origin, but drags from
// outside the sidebar should follow the cursor freely.
const x = constrainToSidebar
? Math.max(
initialOffset.x,
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
)
: currentOffset.x;
const transform = `translate(${x}px, ${y}px)`;
return {
@@ -60,7 +65,14 @@ const DragPlaceholder = () => {
return (
<div style={layerStyles}>
<div style={getItemStyles(initialOffset, currentOffset, ui.sidebarWidth)}>
<div
style={getItemStyles(
initialOffset,
currentOffset,
ui.sidebarWidth,
item.constrainToSidebar !== false
)}
>
<GhostLink
icon={item.icon}
label={item.title || t("Untitled")}
@@ -11,7 +11,7 @@ import Desktop from "~/utils/Desktop";
import { HStack } from "~/components/primitives/HStack";
export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
position: "top" | "bottom";
position?: "top" | "bottom";
title: React.ReactNode;
image: React.ReactNode;
showMoreMenu?: boolean;
+59 -38
View File
@@ -2,6 +2,8 @@ import { observer } from "mobx-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import type Star from "~/models/Star";
import DelayedMount from "~/components/DelayedMount";
import Flex from "~/components/Flex";
@@ -29,6 +31,7 @@ function Starred() {
);
const [reorderStarProps, dropToReorder] = useDropToReorderStar();
const [createStarProps, dropToStarRef] = useDropToCreateStar();
const [sectionStarProps, dropToSectionRef] = useDropToCreateStar();
useEffect(() => {
if (error) {
@@ -42,46 +45,64 @@ function Starred() {
return (
<Flex column>
<Header id="starred" title={t("Starred")}>
<Relative>
{reorderStarProps.isDragging && (
<DropCursor
isActiveDrop={reorderStarProps.isOverCursor}
innerRef={dropToReorder}
position="top"
/>
)}
{createStarProps.isDragging && (
<DropCursor
isActiveDrop={createStarProps.isOverCursor}
innerRef={dropToStarRef}
position="top"
/>
)}
{stars.orderedData
.slice(0, page * STARRED_PAGINATION_LIMIT)
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!loading && !end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={stars.isFetching}
depth={0}
/>
)}
{loading && (
<Flex column>
<DelayedMount>
<PlaceholderCollections />
</DelayedMount>
</Flex>
)}
</Relative>
</Header>
<Section
ref={dropToSectionRef}
$isActiveDrop={
sectionStarProps.isDragging && sectionStarProps.isOverCursor
}
>
<Header id="starred" title={t("Starred")}>
<Relative>
{reorderStarProps.isDragging && (
<DropCursor
isActiveDrop={reorderStarProps.isOverCursor}
innerRef={dropToReorder}
position="top"
/>
)}
{createStarProps.isDragging && (
<DropCursor
isActiveDrop={createStarProps.isOverCursor}
innerRef={dropToStarRef}
position="top"
/>
)}
{stars.orderedData
.slice(0, page * STARRED_PAGINATION_LIMIT)
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!loading && !end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={stars.isFetching}
depth={0}
/>
)}
{loading && (
<Flex column>
<DelayedMount>
<PlaceholderCollections />
</DelayedMount>
</Flex>
)}
</Relative>
</Header>
</Section>
</Flex>
);
}
const Section = styled.div<{ $isActiveDrop?: boolean }>`
border-radius: 8px;
transition: background 100ms ease-in-out;
${(props) =>
props.$isActiveDrop &&
css`
background: ${s("sidebarActiveBackground")};
`}
`;
export default observer(Starred);
@@ -22,6 +22,12 @@ import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
export type DragObject = NavigationNode & {
depth: number;
collectionId: string;
/**
* Whether the drag ghost should stay tethered to the sidebar. Defaults to
* tethered when unset — the placeholder only lets the ghost follow the
* cursor when this is explicitly `false` (e.g. drags from a document list).
*/
constrainToSidebar?: boolean;
};
function useHover(
@@ -105,6 +111,12 @@ export function useDropToCreateStar(getIndex?: () => string) {
>({
accept,
drop: async (item, monitor) => {
// A more specific drop target (e.g. a reorder cursor) has already
// handled this drop, so avoid creating a duplicate star.
if (monitor.didDrop()) {
return;
}
const type = monitor.getItemType();
let model;
@@ -122,7 +134,7 @@ export function useDropToCreateStar(getIndex?: () => string) {
);
},
collect: (monitor) => ({
isOverCursor: !!monitor.isOver(),
isOverCursor: !!monitor.isOver({ shallow: true }),
isDragging: accept.includes(String(monitor.getItemType())),
}),
});
@@ -163,12 +175,16 @@ export function useDropToReorderStar(getIndex?: () => string) {
* @param depth The depth of the node in the sidebar.
* @param document The related Document model.
* @param isEditing Whether the sidebar item is currently being edited.
* @param constrainToSidebar Whether the drag ghost should stay tethered to the
* sidebar. Defaults to true; pass false when dragging from outside the sidebar
* (e.g. a document list) so the ghost follows the cursor.
*/
export function useDragDocument(
node: NavigationNode,
depth: number,
document?: Document,
isEditing?: boolean
isEditing?: boolean,
constrainToSidebar = true
) {
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
@@ -188,6 +204,7 @@ export function useDragDocument(
<Icon initial={initial} value={icon} color={color} />
) : undefined,
collectionId: document?.collectionId || "",
constrainToSidebar,
}) as DragObject,
canDrag: () => !!document?.isActive && !isEditing,
collect: (monitor) => ({
+46
View File
@@ -1,13 +1,17 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import { UserValidation } from "@shared/validations";
import type User from "~/models/User";
import Button from "~/components/Button";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import ImageInput from "~/scenes/Settings/components/ImageInput";
import { client } from "~/utils/ApiClient";
import Text from "./Text";
@@ -142,6 +146,48 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
);
}
export const UserChangeAvatarDialog = observer(function UserChangeAvatarDialog({
user,
onSubmit,
}: Props) {
const { t } = useTranslation();
const handleAvatarChange = async (avatarUrl: string | null) => {
try {
await user.save({ avatarUrl });
toast.success(t("Profile picture updated"));
} catch (err) {
toast.error(err.message);
}
};
const handleAvatarError = (error: string | null | undefined) => {
toast.error(error || t("Unable to upload new profile picture"));
};
return (
<Flex column gap={16}>
<Flex justify="center">
<ImageInput
alt={t("Profile picture")}
onSuccess={handleAvatarChange}
onError={handleAvatarError}
model={user}
showRemoveOption={false}
/>
</Flex>
<Flex justify="flex-end" gap={8}>
{user.avatarUrl && (
<Button onClick={() => handleAvatarChange(null)} neutral>
{t("Remove")}
</Button>
)}
<Button onClick={onSubmit}>{t("Done")}</Button>
</Flex>
</Flex>
);
});
export function UserChangeEmailDialog({ user, onSubmit }: Props) {
const { t } = useTranslation();
const actor = useCurrentUser();
+1
View File
@@ -102,6 +102,7 @@ const StyledContent = styled(m.div)`
const StyledInnerContent = styled(Flex)`
padding: 6px;
padding-bottom: calc(6px + var(--sab, 0px));
height: 100%;
`;
@@ -108,6 +108,9 @@ export const MenuExternalLink = styled.a`
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
${BaseMenuItemCSS}
// Reserve space for the absolutely-positioned disclosure arrow so long
// labels truncate before it rather than overlapping.
padding-inline-end: 32px;
`;
export const MenuSeparator = styled.hr`
+37 -5
View File
@@ -1,3 +1,4 @@
import { debounce } from "es-toolkit/compat";
import {
CaretDownIcon,
CaretUpIcon,
@@ -211,9 +212,31 @@ export default function FindAndReplace({
});
}, [caseSensitive, editor.commands, searchTerm]);
// Searching the document on every keystroke is expensive in long documents
// it traverses the entire doc and rebuilds highlights so debounce.
const debouncedFind = React.useMemo(
() =>
debounce(
(attrs: {
text: string;
caseSensitive: boolean;
regexEnabled: boolean;
}) => {
editor.commands.find(attrs);
},
100
),
[editor.commands]
);
React.useEffect(() => () => debouncedFind.cancel(), [debouncedFind]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
function nextPrevious() {
// Ensure any pending debounced search has run so navigation acts on the
// results for the text currently in the input.
debouncedFind.flush();
if (ev.shiftKey) {
editor.commands.prevSearchMatch();
} else {
@@ -243,7 +266,7 @@ export default function FindAndReplace({
}
}
},
[editor.commands, selectInputText]
[debouncedFind, editor.commands, selectInputText]
);
const handleReplace = React.useCallback(
@@ -274,13 +297,13 @@ export default function FindAndReplace({
ev.stopPropagation();
setSearchTerm(ev.currentTarget.value);
editor.commands.find({
debouncedFind({
text: ev.currentTarget.value,
caseSensitive,
regexEnabled,
});
},
[caseSensitive, editor.commands, regexEnabled]
[caseSensitive, debouncedFind, regexEnabled]
);
const handleReplaceKeyDown = React.useCallback(
@@ -331,6 +354,9 @@ export default function FindAndReplace({
} else {
onClose();
setShowReplace(false);
// Cancel any pending debounced find so it can't reactivate highlights
// after the search has been cleared.
debouncedFind.cancel();
editor.commands.clearSearch();
}
// oxlint-disable-next-line react-hooks/exhaustive-deps
@@ -346,7 +372,10 @@ export default function FindAndReplace({
>
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.prevSearchMatch()}
onClick={() => {
debouncedFind.flush();
editor.commands.prevSearchMatch();
}}
aria-label={t("Previous match")}
>
<CaretUpIcon />
@@ -355,7 +384,10 @@ export default function FindAndReplace({
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.nextSearchMatch()}
onClick={() => {
debouncedFind.flush();
editor.commands.nextSearchMatch();
}}
aria-label={t("Next match")}
>
<CaretDownIcon />
+190
View File
@@ -0,0 +1,190 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { RemoveScroll } from "react-remove-scroll";
import styled from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { collapseSelection } from "@shared/editor/commands/collapseSelection";
import type { MenuItem } from "@shared/editor/types";
import { useTranslation } from "react-i18next";
import Scrollable from "~/components/Scrollable";
import { toMenuItems, toMobileMenuItems } from "~/components/Menu/transformer";
import * as Components from "~/components/primitives/components/Menu";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import type { MenuItem as TMenuItem, MenuItemWithChildren } from "~/types";
import useMobile from "~/hooks/useMobile";
import { mapMenuItems } from "../menus/mapMenuItems";
import { useEditor } from "./EditorContext";
import { useInlineMenuAnchor } from "./useInlineMenuAnchor";
type Props = {
items: MenuItem[];
/** Whether the document is right-to-left. */
rtl: boolean;
};
// The virtual anchor is an invisible zero-size element; the hook positions it
// over the selection and Radix anchors the menu to it.
const anchorStyle: React.CSSProperties = {
position: "fixed",
width: 0,
height: 0,
};
/**
* Renders a selection-toolbar menu inline — a vertical menu anchored to the
* selection with no trigger button — by holding a Radix dropdown `open`
* against a virtual anchor positioned over the selection. Radix provides the
* positioning, collision handling, submenus, and keyboard navigation. Page
* scroll is locked while open (via RemoveScroll, as Radix does for modal
* menus) without enabling Radix's modal mode, which conflicts with the menu
* being opened by an editor selection rather than a trigger.
*/
const InlineMenu: React.FC<Props> = ({ items, rtl }) => {
const { t } = useTranslation();
const { commands, view } = useEditor();
const { state } = view;
const isMobile = useMobile();
const {
ref: anchorRef,
key: anchorKey,
side,
align,
sideOffset,
} = useInlineMenuAnchor(rtl);
const mapped = React.useMemo(
() => mapMenuItems(items, commands, view, state),
[items, commands, view, state]
);
const preventFocus = React.useCallback((ev: Event) => {
ev.preventDefault();
}, []);
// Dismiss the menu by collapsing the selection so the toolbar stops matching.
const handleDismiss = React.useCallback(() => {
collapseSelection()(view.state, view.dispatch);
}, [view]);
if (isMobile) {
return (
<InlineMenuDrawer
items={mapped}
ariaLabel={t("Options")}
onDismiss={handleDismiss}
/>
);
}
return (
<MenuProvider variant="dropdown">
<DropdownMenuPrimitive.Root
key={anchorKey}
open={!!anchorKey}
modal={false}
>
<DropdownMenuPrimitive.Trigger asChild>
<div ref={anchorRef} aria-hidden style={anchorStyle} />
</DropdownMenuPrimitive.Trigger>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
side={side}
align={align}
sideOffset={sideOffset}
collisionPadding={6}
aria-label={t("Options")}
onCloseAutoFocus={preventFocus}
onInteractOutside={handleDismiss}
onEscapeKeyDown={handleDismiss}
asChild
>
<RemoveScroll as={Slot} allowPinchZoom>
<Components.MenuContent
maxHeightVar="--radix-dropdown-menu-content-available-height"
transformOriginVar="--radix-dropdown-menu-content-transform-origin"
hiddenScrollbars
>
<EventBoundary>{toMenuItems(mapped)}</EventBoundary>
</Components.MenuContent>
</RemoveScroll>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Root>
</MenuProvider>
);
};
// Time for the drawer's close animation to play before the selection is
// collapsed (which unmounts the menu).
const DRAWER_CLOSE_MS = 500;
type InlineMenuDrawerProps = {
items: TMenuItem[];
ariaLabel: string;
/** Collapse the selection so the toolbar stops rendering the menu. */
onDismiss: () => void;
};
/**
* Mobile presentation of the inline menu: a bottom drawer with submenu drill-in,
* matching the other menus. The menu is held open while the selection matches;
* closing animates the drawer out before collapsing the selection.
*/
function InlineMenuDrawer({
items,
ariaLabel,
onDismiss,
}: InlineMenuDrawerProps) {
const [open, setOpen] = React.useState(true);
const [submenuName, setSubmenuName] = React.useState<string>();
const close = React.useCallback(() => {
setOpen(false);
setTimeout(() => {
setSubmenuName(undefined);
onDismiss();
}, DRAWER_CLOSE_MS);
}, [onDismiss]);
const handleOpenChange = React.useCallback(
(isOpen: boolean) => {
if (!isOpen) {
close();
}
},
[close]
);
const menuItems = React.useMemo(() => {
if (!items.length || !submenuName) {
return items;
}
const submenu = items.find(
(item) => item.type === "submenu" && item.title === submenuName
) as MenuItemWithChildren | undefined;
return submenu?.items ?? items;
}, [items, submenuName]);
const content = toMobileMenuItems(menuItems, close, setSubmenuName);
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerContent aria-label={ariaLabel} aria-describedby={undefined}>
<DrawerTitle hidden>{ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>{content}</StyledScrollable>
</DrawerContent>
</Drawer>
);
}
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export default InlineMenu;
+76 -4
View File
@@ -1,6 +1,7 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { runInAction } from "mobx";
import {
DocumentIcon,
PlusIcon,
@@ -14,11 +15,20 @@ import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import type { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import {
dateToReadable,
dateToRelativeReadable,
parseISODate,
toISODate,
} from "@shared/utils/date";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate";
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import { DynamicCalendarIcon } from "@shared/components/DynamicCalendarIcon";
import Flex from "~/components/Flex";
import {
DateSection,
DocumentsSection,
UserSection,
CollectionsSection,
@@ -26,18 +36,20 @@ import {
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { client } from "~/utils/ApiClient";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import { runInAction } from "mobx";
interface MentionItem extends MenuItem {
attrs: {
id: string;
type: MentionType;
modelId: string;
label: string;
// Date mentions intentionally omit a label — their text is derived from
// the ISO `modelId` so nothing human-readable is persisted.
label?: string;
actorId?: string;
};
}
@@ -47,15 +59,72 @@ type Props = Omit<
"renderMenuItem" | "items" | "embeds"
>;
function MentionMenu({ search, isActive, ...rest }: Props) {
function MentionMenu({ search = "", isActive, ...rest }: Props) {
const [loaded, setLoaded] = useState(false);
const { t } = useTranslation();
const { auth, documents, users, collections, groups } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const userLocale = useUserLocale();
const maxResultsInSection = search ? 25 : 5;
// Surface a date suggestion when the search query parses as a natural
// language date (e.g. "tomorrow", "next friday", "jan 2"). Parsing is
// asynchronous as chrono-node is loaded lazily, so the result is held in
// state and applied once resolved.
const [parsedISODate, setParsedISODate] = useState<string | undefined>();
useEffect(() => {
if (!search) {
setParsedISODate(undefined);
return;
}
let cancelled = false;
void parseNaturalLanguageDate(search)
.then((date) => {
if (!cancelled) {
setParsedISODate(date ? toISODate(date) : undefined);
}
})
.catch(() => {
// Parsing failed (e.g. the chrono chunk failed to load); drop the
// suggestion rather than leaving a stale one.
if (!cancelled) {
setParsedISODate(undefined);
}
});
return () => {
cancelled = true;
};
}, [search]);
let dateItems: MentionItem[] = [];
if (actorId && parsedISODate) {
const title = dateToRelativeReadable(parsedISODate, t, userLocale);
const subtitle = dateToReadable(parsedISODate, userLocale);
dateItems = [
{
name: "mention",
icon: (
<DynamicCalendarIcon day={parseISODate(parsedISODate)?.getDate()} />
),
title,
subtitle: title !== subtitle ? subtitle : undefined,
section: DateSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Date,
modelId: parsedISODate,
actorId,
},
} as MentionItem,
];
}
const { loading, request } = useRequest(
useCallback(async () => {
const res = await client.post("/suggestions.mention", {
@@ -87,7 +156,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
// Computed in the render body so MobX observer can track store access
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
// runs outside the reactive context and triggered MobX warnings.
const items: MentionItem[] = actorId
const mentionItems: MentionItem[] = actorId
? users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
@@ -253,9 +322,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
])
: [];
const items: MentionItem[] = [...dateItems, ...mentionItems];
const handleSelect = useCallback(
async (item: MentionItem) => {
if (
item.attrs.type === MentionType.Date ||
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
+12 -1
View File
@@ -11,7 +11,7 @@ import {
} from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import type { MenuItem } from "@shared/editor/types";
import { MenuType, type MenuItem } from "@shared/editor/types";
import useBoolean from "~/hooks/useBoolean";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
@@ -24,6 +24,7 @@ import { MediaLinkEditor } from "./MediaLinkEditor";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
import InlineMenu from "./InlineMenu";
import { isModKey } from "@shared/utils/keyboard";
type Props = {
@@ -264,6 +265,16 @@ export function SelectionToolbar(props: Props) {
setActiveToolbar(null);
};
// Inline menus render as a vertical menu anchored to the selection rather
// than as a horizontal toolbar with trigger buttons.
if (
matched?.variant === MenuType.inline &&
activeToolbar === Toolbar.Menu &&
items.length
) {
return <InlineMenu items={items} rtl={rtl} />;
}
return (
<FloatingToolbar
align={align}
+1 -1
View File
@@ -35,7 +35,7 @@ import { MenuHeader } from "~/components/primitives/components/Menu";
export type Props<T extends MenuItem = MenuItem> = {
rtl: boolean;
isActive: boolean;
search: string;
search?: string;
trigger: string | string[];
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
+6 -63
View File
@@ -7,6 +7,7 @@ import type { MenuItem } from "@shared/editor/types";
import { hideScrollbars, s } from "@shared/styles";
import { TooltipProvider } from "~/components/TooltipContext";
import type { MenuItem as TMenuItem } from "~/types";
import { mapMenuItems } from "../menus/mapMenuItems";
import { useEditor } from "./EditorContext";
import { MediaDimension } from "./MediaDimension";
import ToolbarButton from "./ToolbarButton";
@@ -49,69 +50,11 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
return [];
}
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
}
if (commands[menuItem.name]) {
closeHistory(view);
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
closeHistory(view);
} else if (menuItem.onClick) {
menuItem.onClick();
}
};
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
if ("content" in child) {
return {
type: "custom",
visible: child.visible,
content: child.content,
};
}
const resolvedChildren = resolveChildren(child.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(c) => "preventCloseCondition" in c
);
return {
type: "submenu",
title: child.label,
icon: child.icon,
visible: child.visible,
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapChildren(resolvedChildren),
};
}
return {
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
});
const resolvedItemChildren = resolveChildren(item.children);
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
const resolvedItemChildren =
typeof item.children === "function" ? item.children() : item.children;
return resolvedItemChildren
? mapMenuItems(resolvedItemChildren, commands, view, state)
: [];
}, [isOpen, commands]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
@@ -0,0 +1,152 @@
import { selectedRect } from "prosemirror-tables";
import * as React from "react";
import type { EditorView } from "prosemirror-view";
import { ColumnSelection } from "@shared/editor/selection/ColumnSelection";
import { RowSelection } from "@shared/editor/selection/RowSelection";
import { isTableSelected } from "@shared/editor/queries/table";
import { useEditor } from "./EditorContext";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
const DEFAULT_SIDE_OFFSET = 4;
// Column and row menus open next to a grip handle. The grip is modelled as a
// strip just outside the cell edge so the two distances are independent:
// opening to the outside clears the grip (strip thickness + offset), while
// flipping across sits only a small gap (offset) away.
const OUTSIDE_CLEARANCE = 20;
const FLIP_GAP = 0;
const GRIP_INSET = OUTSIDE_CLEARANCE - FLIP_GAP;
const GRIP_SIDE_OFFSET = FLIP_GAP;
type Anchor = {
/** Viewport rect to anchor the menu to. */
top: number;
left: number;
width: number;
height: number;
/** Which side of the anchor the menu opens towards. */
side: Side;
/** How the menu aligns along the anchor edge. */
align: Align;
/** Distance in pixels between the anchor and the menu. */
sideOffset: number;
/** Stable identifier for the anchored target, changes when it moves. */
key: string;
};
/**
* Computes the rect and placement to anchor an inline selection menu to, based
* on the current table/column/row selection. The menu opens to the "outside"
* of the table (above a column, beside a row) to cover the least content, and
* is centered on the anchor for minimal pointer movement. Returns null when
* there is no supported selection.
*
* @param view - the editor view.
* @param rtl - whether the document is right-to-left.
* @returns the anchor, or null.
*/
function getAnchor(view: EditorView, rtl: boolean): Anchor | null {
const { state } = view;
const { selection } = state;
if (isTableSelected(state)) {
const rect = selectedRect(state);
const bounds = (
view.domAtPos(rect.tableStart).node as HTMLElement
).getBoundingClientRect();
// A horizontal line at the table's top edge so it stays near the top
// whether the menu opens above or flips below.
return {
top: bounds.top,
left: bounds.left,
width: bounds.width,
height: 0,
side: "top",
align: "start",
sideOffset: DEFAULT_SIDE_OFFSET,
key: `table-${rect.tableStart}`,
};
}
if (selection instanceof ColumnSelection && selection.isColSelection()) {
const rect = selectedRect(state);
const cell = (
view.domAtPos(rect.tableStart).node as HTMLElement
).querySelector(`tr > *:nth-child(${rect.left + 1})`);
if (cell instanceof HTMLElement) {
const bounds = cell.getBoundingClientRect();
// A strip just above the column's top edge (the grip), spanning the
// column width so the menu centers on the column.
return {
top: bounds.top - GRIP_INSET,
left: bounds.left,
width: bounds.width,
height: GRIP_INSET,
side: "top",
align: "center",
sideOffset: GRIP_SIDE_OFFSET,
key: `col-${rect.tableStart}-${rect.left}`,
};
}
}
if (selection instanceof RowSelection && selection.isRowSelection()) {
const rect = selectedRect(state);
const cell = (
view.domAtPos(rect.tableStart).node as HTMLElement
).querySelector(`tr:nth-child(${rect.top + 1}) > *`);
if (cell instanceof HTMLElement) {
const bounds = cell.getBoundingClientRect();
// A strip just outside the row's grip edge (left, or right in RTL),
// spanning the row height so the menu centers on the row.
return {
top: bounds.top,
left: rtl ? bounds.right : bounds.left - GRIP_INSET,
width: GRIP_INSET,
height: bounds.height,
side: rtl ? "right" : "left",
align: "center",
sideOffset: GRIP_SIDE_OFFSET,
key: `row-${rect.tableStart}-${rect.top}`,
};
}
}
return null;
}
/**
* Positions an invisible virtual anchor element over the current table, column,
* or row selection so a Radix dropdown can anchor an inline menu to it. The
* returned `key` changes when the anchored target changes; spread it onto the
* menu root so Radix repositions for a new target.
*
* @param rtl - whether the document is right-to-left.
* @returns the anchor ref to attach to the virtual trigger, the target key, and
* the side/align the menu should open with.
*/
export function useInlineMenuAnchor(rtl: boolean) {
const { view } = useEditor();
const ref = React.useRef<HTMLDivElement>(null);
const anchor = getAnchor(view, rtl);
React.useLayoutEffect(() => {
const element = ref.current;
if (element && anchor) {
element.style.top = `${anchor.top}px`;
element.style.left = `${anchor.left}px`;
element.style.width = `${anchor.width}px`;
element.style.height = `${anchor.height}px`;
}
});
return {
ref,
key: anchor?.key,
side: anchor?.side ?? "top",
align: anchor?.align ?? "start",
sideOffset: anchor?.sideOffset ?? DEFAULT_SIDE_OFFSET,
};
}
@@ -61,7 +61,7 @@ export default class ClipboardTextSerializer extends Extension {
.map((node) => ProsemirrorHelper.toPlainText(node))
.join("\n")
: mdSerializer.serialize(slice.content, {
softBreak: true,
commonMark: true,
});
},
},
+48 -4
View File
@@ -381,6 +381,11 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
}
});
// Tracks already-seen match positions so duplicate matches (possible because
// we search the deburred text concatenated with the original) can be skipped
// in constant time rather than rescanning the entire results array.
const seen = new Set<string>();
mergedTextNodes.forEach((node) => {
const { text = "", pos, type } = node;
try {
@@ -405,11 +410,13 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
continue;
}
// Check if already exists in results, possible due to duplicated
// search string on L257
if (this.results.some((r) => r.from === from && r.to === to)) {
// Check if already exists in results, possible because we search
// over `deburr(text) + text`
const key = `${from}:${to}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
this.results.push({ from, to, type });
}
@@ -483,6 +490,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
}
}
this.highlightRanges = allRanges;
CSS.highlights.set("search-results", new Highlight(...allRanges));
if (currentRanges.length) {
CSS.highlights.set(
@@ -495,6 +503,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
}
private clearHighlights() {
this.highlightRanges = [];
if (!supportsHighlightAPI) {
return;
}
@@ -503,6 +512,25 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
this.currentHighlightRange = undefined;
}
/**
* Determine whether the highlight ranges need to be rebuilt against the live
* DOM. The CSS Custom Highlight API holds static ranges that detach when the
* editor re-renders its DOM without changing the doc, so highlights are stale
* when a built range's nodes have disconnected, or when some matches have not
* yet been resolved to ranges (e.g. inside a node view that mounts later).
*
* @returns whether the highlights should be rebuilt.
*/
private highlightsStale() {
if (this.highlightRanges.length < this.results.length) {
return true;
}
return this.highlightRanges.some(
(range) =>
!range.startContainer.isConnected || !range.endContainer.isConnected
);
}
private handleEscape = () => {
const params = new URLSearchParams(window.location.search);
if (params.has("q")) {
@@ -536,6 +564,8 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
private currentHighlightRange?: StaticRange;
private highlightRanges: StaticRange[] = [];
get allowInReadOnly() {
return true;
}
@@ -604,13 +634,27 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
return {
update: (view) => {
const generation = pluginKey.getState(view.state) as number;
// The results changed (search ran, doc changed, fold toggled), so
// always rebuild.
if (generation !== lastGeneration) {
lastGeneration = generation;
this.updateHighlights();
return;
}
// Results unchanged: only rebuild when the static highlight ranges
// have detached from a DOM re-render that didn't bump the generation.
if (this.searchTerm && this.highlightsStale()) {
this.updateHighlights();
}
},
destroy: () => {
this.clearHighlights();
// The highlight registry is global and keyed by fixed names, so
// only tear down highlights when this editor actually owns an
// active search — otherwise an unmounting editor could wipe the
// highlights another editor just set during a route transition.
if (this.searchTerm) {
this.clearHighlights();
}
},
};
},
+15 -8
View File
@@ -41,10 +41,9 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
}
get plugins() {
const isHoverTarget = (target: Element | null, view: EditorView) =>
const isHoverTarget = (target: Element | null) =>
target instanceof HTMLElement &&
this.editor.elementRef.current?.contains(target) &&
(!view.editable || (view.editable && !view.hasFocus()));
this.editor.elementRef.current?.contains(target);
let hoveringTimeout: ReturnType<typeof setTimeout>;
@@ -52,11 +51,11 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
new Plugin({
props: {
handleDOMEvents: {
mouseover: (view: EditorView, event: MouseEvent) => {
mouseover: (_view: EditorView, event: MouseEvent) => {
const target = (event.target as HTMLElement)?.closest(
".use-hover-preview"
);
if (isHoverTarget(target, view)) {
if (isHoverTarget(target)) {
hoveringTimeout = setTimeout(
action(async () => {
const element = target as HTMLElement;
@@ -79,7 +78,15 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
documentId,
});
if (unfurl) {
// The fetch is async, so the pointer may have already
// left the target (or the node may have been removed) by
// the time it resolves only show the preview if the
// element is still hovered.
if (
unfurl &&
element.isConnected &&
element.matches(":hover")
) {
this.state.activeLinkElement = element;
this.state.unfurlId = transformedUrl;
} else {
@@ -94,11 +101,11 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
}
return false;
},
mouseout: action((view: EditorView, event: MouseEvent) => {
mouseout: action((_view: EditorView, event: MouseEvent) => {
const target = (event.target as HTMLElement)?.closest(
".use-hover-preview"
);
if (isHoverTarget(target, view)) {
if (isHoverTarget(target)) {
clearTimeout(hoveringTimeout);
this.state.activeLinkElement = null;
}
+2 -1
View File
@@ -13,6 +13,7 @@ import {
import * as Y from "yjs";
import Extension from "@shared/editor/lib/Extension";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { Second } from "@shared/utils/time";
type UserAwareness = {
@@ -107,7 +108,7 @@ export default class Multiplayer extends Extension<MultiplayerOptions> {
return {
style: `background-color: ${u.color}${opacity}`,
class: "ProseMirror-yjs-selection",
class: EditorStyleHelper.multiplayerSelection,
};
};
+2 -6
View File
@@ -55,15 +55,11 @@ export default class PasteHandler extends Extension {
},
handleDOMEvents: {
keydown: (_, event) => {
if (event.key === "Shift") {
this.shiftKey = true;
}
this.shiftKey = event.shiftKey;
return false;
},
keyup: (_, event) => {
if (event.key === "Shift") {
this.shiftKey = false;
}
this.shiftKey = event.shiftKey;
return false;
},
},
+7 -1
View File
@@ -7,7 +7,10 @@ import Extension from "@shared/editor/lib/Extension";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import type { SelectionToolbarMenuDescriptor } from "@shared/editor/types";
import {
MenuType,
type SelectionToolbarMenuDescriptor,
} from "@shared/editor/types";
import { SelectionToolbar } from "../components/SelectionToolbar";
import getAttachmentMenuItems from "../menus/attachment";
import getCodeMenuItems from "../menus/code";
@@ -62,16 +65,19 @@ export default class SelectionToolbarExtension extends Extension {
},
{
priority: 90,
variant: MenuType.inline,
matches: (ctx) => ctx.isTableSelected,
getItems: (ctx) => getTableMenuItems(ctx),
},
{
priority: 85,
variant: MenuType.inline,
matches: (ctx) => ctx.colIndex !== undefined,
getItems: (ctx) => getTableColMenuItems(ctx),
},
{
priority: 80,
variant: MenuType.inline,
matches: (ctx) => ctx.rowIndex !== undefined,
getItems: (ctx) => getTableRowMenuItems(ctx),
},
+1 -1
View File
@@ -46,7 +46,7 @@ export default class Suggestion<
: `(?:${triggers.map(escapeRegExp).join("|")})`;
this.openRegex = new RegExp(
`(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
`(?:^|\\s|\\(|\\+|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.\\-_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
+13 -17
View File
@@ -17,7 +17,6 @@ import {
WarningIcon,
InfoIcon,
AttachmentIcon,
ClockIcon,
CalendarIcon,
MathIcon,
DoneIcon,
@@ -26,9 +25,12 @@ import {
} from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { v4 as uuidv4 } from "uuid";
import type { TFunction } from "i18next";
import Image from "@shared/editor/components/Img";
import type { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { toISODate } from "@shared/utils/date";
import { metaDisplay } from "@shared/utils/keyboard";
import Desktop from "~/utils/Desktop";
@@ -124,8 +126,6 @@ export default function blockMenuItems(
keywords: "pdf upload attach",
attrs: {
accept: "application/pdf",
width: 300,
height: 424,
preview: true,
},
},
@@ -186,22 +186,18 @@ export default function blockMenuItems(
attrs: { markup: "***" },
},
{
name: "date",
// Inserts a date mention for today. Supersedes the deprecated "Current
// date/time" commands that inserted a static string or template token.
name: "mention",
title: t("Current date"),
keywords: "clock today",
icon: <CalendarIcon />,
},
{
name: "time",
title: t("Current time"),
keywords: "clock now",
icon: <ClockIcon />,
},
{
name: "datetime",
title: t("Current date and time"),
keywords: "clock today date",
keywords: "clock today date time now",
icon: <CalendarIcon />,
appendSpace: true,
attrs: () => ({
id: uuidv4(),
type: MentionType.Date,
modelId: toISODate(new Date()),
}),
},
{
name: "separator",
+80
View File
@@ -0,0 +1,80 @@
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { closeHistory } from "@shared/editor/lib/closeHistory";
import type { CommandFactory } from "@shared/editor/lib/Extension";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem as TMenuItem } from "~/types";
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
/**
* Maps editor `MenuItem`s into the primitive `MenuItem`s consumed by
* `toMenuItems`. Shared by the toolbar dropdown and the inline menu so menu
* presentation stays consistent. Resolves nested children into submenus and
* binds each leaf to its editor command (or `onClick`).
*
* @param items - the editor menu items to map.
* @param commands - the editor command registry.
* @param view - the editor view, used to checkpoint history around commands.
* @param state - the editor state, used to resolve dynamic attrs and active state.
* @returns the mapped primitive menu items.
*/
export function mapMenuItems(
items: MenuItem[],
commands: Record<string, CommandFactory>,
view: EditorView,
state: EditorState
): TMenuItem[] {
const handleClick = (item: MenuItem) => () => {
if (!item.name) {
return;
}
if (commands[item.name]) {
closeHistory(view);
commands[item.name](
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
);
closeHistory(view);
} else if (item.onClick) {
item.onClick();
}
};
return items.map((item) => {
if (item.name === "separator") {
return { type: "separator", visible: item.visible };
}
if ("content" in item) {
return { type: "custom", visible: item.visible, content: item.content };
}
const resolvedChildren = resolveChildren(item.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(child) => "preventCloseCondition" in child
);
return {
type: "submenu",
title: item.label,
icon: item.icon,
visible: item.visible,
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapMenuItems(resolvedChildren, commands, view, state),
};
}
return {
type: "button",
title: item.label,
icon: item.icon,
dangerous: item.dangerous,
visible: item.visible,
selected: item.active !== undefined ? item.active(state) : undefined,
onClick: handleClick(item),
};
});
}
+11 -14
View File
@@ -15,9 +15,7 @@ import { TableLayout } from "@shared/editor/types";
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function tableMenuItems(
ctx: SelectionContext
): MenuItem[] {
export default function tableMenuItems(ctx: SelectionContext): MenuItem[] {
if (ctx.readOnly) {
return [];
}
@@ -30,33 +28,32 @@ export default function tableMenuItems(
return [
{
name: "setTableAttr",
tooltip: isFullWidth ? t("Default width") : t("Full width"),
label: isFullWidth ? t("Default width") : t("Full width"),
icon: <AlignFullWidthIcon />,
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
active: () => isFullWidth,
},
{
name: "distributeColumns",
tooltip: t("Distribute columns"),
label: t("Distribute columns"),
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
{
name: "deleteTable",
tooltip: t("Delete table"),
icon: <TrashIcon />,
name: "exportTable",
label: t("Export as CSV"),
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
icon: <DownloadIcon />,
},
{
name: "separator",
},
{
name: "exportTable",
tooltip: t("Export as CSV"),
label: "CSV",
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
icon: <DownloadIcon />,
name: "deleteTable",
label: t("Delete table"),
dangerous: true,
icon: <TrashIcon />,
},
];
}
+122 -118
View File
@@ -5,7 +5,6 @@ import {
AlignCenterIcon,
InsertLeftIcon,
InsertRightIcon,
MoreIcon,
PaletteIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
@@ -24,7 +23,11 @@ import {
tableHasRowspan,
} from "@shared/editor/queries/table";
import { t } from "i18next";
import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types";
import type {
MenuItem,
NodeAttrMark,
SelectionContext,
} from "@shared/editor/types";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
@@ -65,9 +68,7 @@ function getColumnColors(state: EditorState, colIndex: number): Set<string> {
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function tableColMenuItems(
ctx: SelectionContext
): MenuItem[] {
export default function tableColMenuItems(ctx: SelectionContext): MenuItem[] {
if (ctx.readOnly) {
return [];
}
@@ -94,60 +95,65 @@ export default function tableColMenuItems(
return [
{
name: "setColumnAttr",
tooltip: t("Align left"),
icon: <AlignLeftIcon />,
attrs: { index, alignment: "left" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "left",
}),
},
{
name: "setColumnAttr",
tooltip: t("Align center"),
label: t("Align"),
icon: <AlignCenterIcon />,
attrs: { index, alignment: "center" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "center",
}),
children: [
{
name: "setColumnAttr",
label: t("Align left"),
icon: <AlignLeftIcon />,
attrs: { index, alignment: "left" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "left",
}),
},
{
name: "setColumnAttr",
label: t("Align center"),
icon: <AlignCenterIcon />,
attrs: { index, alignment: "center" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "center",
}),
},
{
name: "setColumnAttr",
label: t("Align right"),
icon: <AlignRightIcon />,
attrs: { index, alignment: "right" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "right",
}),
},
],
},
{
name: "setColumnAttr",
tooltip: t("Align right"),
icon: <AlignRightIcon />,
attrs: { index, alignment: "right" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "right",
}),
},
{
name: "separator",
},
{
name: "sortTable",
tooltip: t("Sort ascending"),
attrs: { index, direction: "asc" },
label: t("Sort"),
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
children: [
{
name: "sortTable",
label: t("Sort ascending"),
attrs: { index, direction: "asc" },
icon: <SortAscendingIcon />,
},
{
name: "sortTable",
label: t("Sort descending"),
attrs: { index, direction: "desc" },
icon: <SortDescendingIcon />,
},
],
},
{
name: "sortTable",
tooltip: t("Sort descending"),
attrs: { index, direction: "desc" },
icon: <SortDescendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "separator",
},
{
tooltip: t("Background color"),
label: t("Background"),
icon:
colColors.size > 1 ? (
<CircleIcon color="rainbow" />
@@ -161,7 +167,7 @@ export default function tableColMenuItems(
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: t("None"),
icon: <DottedCircleIcon retainColor color="transparent" />,
icon: <DottedCircleIcon color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
@@ -205,71 +211,69 @@ export default function tableColMenuItems(
],
},
{
icon: <MoreIcon />,
children: [
{
name: "toggleHeaderColumn",
label: t("Toggle header"),
icon: <TableHeaderColumnIcon />,
visible: index === 0,
},
{
name: rtl ? "addColumnAfter" : "addColumnBefore",
label: rtl ? t("Insert after") : t("Insert before"),
icon: <InsertLeftIcon />,
attrs: { index },
},
{
name: rtl ? "addColumnBefore" : "addColumnAfter",
label: rtl ? t("Insert before") : t("Insert after"),
icon: <InsertRightIcon />,
attrs: { index },
},
{
name: "moveTableColumn",
label: t("Move left"),
icon: <ArrowLeftIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableColumn",
label: t("Move right"),
icon: <ArrowRightIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.width - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: t("Merge cells"),
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: t("Split cell"),
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "distributeColumns",
visible: selectedCols.length > 1,
label: t("Distribute columns"),
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
{
name: "deleteColumn",
dangerous: true,
label: t("Delete"),
icon: <TrashIcon />,
},
],
name: "separator",
},
{
name: "toggleHeaderColumn",
label: t("Toggle header"),
icon: <TableHeaderColumnIcon />,
visible: index === 0,
},
{
name: rtl ? "addColumnAfter" : "addColumnBefore",
label: rtl ? t("Insert after") : t("Insert before"),
icon: <InsertLeftIcon />,
attrs: { index },
},
{
name: rtl ? "addColumnBefore" : "addColumnAfter",
label: rtl ? t("Insert before") : t("Insert after"),
icon: <InsertRightIcon />,
attrs: { index },
},
{
name: "moveTableColumn",
label: t("Move left"),
icon: <ArrowLeftIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableColumn",
label: t("Move right"),
icon: <ArrowRightIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.width - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: t("Merge cells"),
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: t("Split cell"),
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "distributeColumns",
visible: selectedCols.length > 1,
label: t("Distribute columns"),
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
{
name: "deleteColumn",
dangerous: true,
label: t("Delete"),
icon: <TrashIcon />,
},
];
}
+65 -66
View File
@@ -2,7 +2,6 @@ import {
TrashIcon,
InsertAboveIcon,
InsertBelowIcon,
MoreIcon,
PaletteIcon,
TableHeaderRowIcon,
TableSplitCellsIcon,
@@ -16,7 +15,11 @@ import {
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { t } from "i18next";
import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types";
import type {
MenuItem,
NodeAttrMark,
SelectionContext,
} from "@shared/editor/types";
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
@@ -56,9 +59,7 @@ function getRowColors(state: EditorState, rowIndex: number): Set<string> {
* @param ctx - the current selection context.
* @returns an array of menu items.
*/
export default function tableRowMenuItems(
ctx: SelectionContext
): MenuItem[] {
export default function tableRowMenuItems(ctx: SelectionContext): MenuItem[] {
if (ctx.readOnly) {
return [];
}
@@ -83,7 +84,42 @@ export default function tableRowMenuItems(
return [
{
tooltip: t("Background color"),
name: "toggleHeaderRow",
label: t("Toggle header"),
icon: <TableHeaderRowIcon />,
visible: index === 0,
},
{
name: "addRowBefore",
label: t("Insert before"),
icon: <InsertAboveIcon />,
attrs: { index },
},
{
name: "addRowAfter",
label: t("Insert after"),
icon: <InsertBelowIcon />,
attrs: { index },
},
{
name: "moveTableRow",
label: t("Move up"),
icon: <ArrowUpIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableRow",
label: t("Move down"),
icon: <ArrowDownIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.height - 1,
},
{
name: "separator",
},
{
label: t("Background"),
icon:
rowColors.size > 1 ? (
<CircleIcon color="rainbow" />
@@ -97,7 +133,7 @@ export default function tableRowMenuItems(
{
name: "toggleRowBackgroundAndCollapseSelection",
label: t("None"),
icon: <DottedCircleIcon retainColor color="transparent" />,
icon: <DottedCircleIcon color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
@@ -141,65 +177,28 @@ export default function tableRowMenuItems(
],
},
{
icon: <MoreIcon />,
children: [
{
name: "toggleHeaderRow",
label: t("Toggle header"),
icon: <TableHeaderRowIcon />,
visible: index === 0,
},
{
name: "addRowBefore",
label: t("Insert before"),
icon: <InsertAboveIcon />,
attrs: { index },
},
{
name: "addRowAfter",
label: t("Insert after"),
icon: <InsertBelowIcon />,
attrs: { index },
},
{
name: "moveTableRow",
label: t("Move up"),
icon: <ArrowUpIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableRow",
label: t("Move down"),
icon: <ArrowDownIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.height - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: t("Merge cells"),
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: t("Split cell"),
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "separator",
},
{
name: "deleteRow",
label: t("Delete"),
dangerous: true,
icon: <TrashIcon />,
},
],
name: "separator",
},
{
name: "mergeCells",
label: t("Merge cells"),
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: t("Split cell"),
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "separator",
},
{
name: "deleteRow",
label: t("Delete"),
dangerous: true,
icon: <TrashIcon />,
},
];
}
+23
View File
@@ -20,6 +20,7 @@ import {
UserSuspendDialog,
UserChangeNameDialog,
UserChangeEmailDialog,
UserChangeAvatarDialog,
} from "~/components/UserDialogs";
/**
@@ -33,6 +34,21 @@ export function useUserMenuActions(targetUser: User | null) {
const { t } = useTranslation();
const can = usePolicy(targetUser ?? ({} as User));
const openAvatarDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Change profile picture"),
content: (
<UserChangeAvatarDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const openNameDialog = React.useCallback(() => {
if (!targetUser) {
return;
@@ -127,6 +143,12 @@ export function useUserMenuActions(targetUser: User | null) {
visible: can.demote || can.promote,
children: roleChangeActions,
}),
createAction({
name: `${t("Change profile picture")}`,
section: UserSection,
visible: can.update,
perform: openAvatarDialog,
}),
createAction({
name: `${t("Change name")}`,
section: UserSection,
@@ -177,6 +199,7 @@ export function useUserMenuActions(targetUser: User | null) {
can.update,
can.resendInvite,
roleChangeActions,
openAvatarDialog,
openNameDialog,
openEmailDialog,
resendInvitation,
+1
View File
@@ -91,6 +91,7 @@ function CommentMenu({
action={rootAction}
align="end"
ariaLabel={t("Comment options")}
modal={false}
>
<OverflowMenuButton className={className} />
</DropdownMenu>
+1 -1
View File
@@ -29,7 +29,7 @@ export class ProsemirrorHelper {
);
const markdown = serializer.serialize(doc, {
softBreak: true,
commonMark: true,
});
return markdown;
};
+2
View File
@@ -11,6 +11,7 @@ import Route from "~/components/ProfiledRoute";
import WebsocketProvider from "~/components/WebsocketProvider";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQueryNotices from "~/hooks/useQueryNotices";
import lazy from "~/utils/lazyWithRetry";
import {
archivePath,
@@ -53,6 +54,7 @@ const RedirectDocument = ({
* the user to be logged in.
*/
function AuthenticatedRoutes() {
useQueryNotices();
const team = useCurrentTeam();
const can = usePolicy(team);
-2
View File
@@ -5,7 +5,6 @@ import DelayedMount from "~/components/DelayedMount";
import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute";
import env from "~/env";
import useQueryNotices from "~/hooks/useQueryNotices";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug as documentSlug } from "~/utils/routeHelpers";
import useAutoRefresh from "~/hooks/useAutoRefresh";
@@ -18,7 +17,6 @@ const Logout = lazy(() => import("~/scenes/Logout"));
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
export default function Routes() {
useQueryNotices();
useAutoRefresh();
return (
@@ -1,9 +1,8 @@
import { format as formatDate } from "date-fns";
import { CalendarIcon } from "outline-icons";
import * as React from "react";
import { DayPicker } from "react-day-picker";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import styled from "styled-components";
import { Calendar } from "@shared/components/Calendar";
import { dateLocale } from "@shared/utils/date";
import Button from "~/components/Button";
import {
@@ -21,25 +20,10 @@ type Props = {
const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const theme = useTheme();
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const styles = React.useMemo(
() =>
({
"--rdp-caption-font-size": "16px",
"--rdp-cell-size": "34px",
"--rdp-selected-text": theme.accentText,
"--rdp-accent-color": theme.accent,
"--rdp-accent-color-dark": theme.accent,
"--rdp-background-color": theme.listItemHoverBackground,
"--rdp-background-color-dark": theme.listItemHoverBackground,
}) as React.CSSProperties,
[theme]
);
const handleSelect = React.useCallback(
(date: Date) => {
setOpen(false);
@@ -51,7 +35,7 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<StyledPopoverButton icon={<Icon />} neutral>
<StyledPopoverButton neutral>
{selectedDate
? formatDate(selectedDate, "MMM dd, yyyy", { locale })
: t("Choose a date")}
@@ -63,12 +47,12 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
side="right"
shrink
>
<DayPicker
<Calendar
required
mode="single"
selected={selectedDate}
onSelect={handleSelect}
style={styles}
locale={locale}
disabled={{ before: new Date() }}
/>
</PopoverContent>
@@ -76,23 +60,9 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
);
};
const Icon = () => (
<IconWrapper>
<CalendarIcon />
</IconWrapper>
);
const StyledPopoverButton = styled(Button)`
margin-top: 12px;
width: 150px;
`;
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
`;
export default ExpiryDatePicker;
+1 -2
View File
@@ -13,7 +13,6 @@ import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { dateToExpiry } from "~/utils/date";
import "react-day-picker/dist/style.css";
import ExpiryDatePicker from "./components/ExpiryDatePicker";
import { ExpiryType, ExpiryValues, calculateExpiryDate } from "./utils";
@@ -123,7 +122,7 @@ function ApiKeyNew({ onSubmit }: Props) {
)}
.
</Text>
<Flex align="center" gap={16}>
<Flex align="center" gap={8}>
<StyledExpirySelect
options={expiryOptions}
value={expiryType}
+1 -1
View File
@@ -179,7 +179,7 @@ const CollectionScene = observer(function CollectionScene_() {
<Notices collection={collection} />
<Header
collection={collection}
isEditing={isEditRoute && !!user?.separateEditMode}
isEditing={isEditRoute || !user?.separateEditMode}
/>
<PinnedDocuments
@@ -13,6 +13,38 @@ export interface Example {
* Node and mark names are matched against those defined in the shared editor schema.
*/
export const examples: Example[] = [
{
id: "word-replacement",
name: "Word replacement (interleaved)",
before: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "no-await-in-loop",
},
],
},
],
},
after: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "jsx-no-jsx-as-prop",
},
],
},
],
},
},
{
id: "simple-text",
name: "Simple text change",
@@ -632,6 +664,846 @@ export const examples: Example[] = [
],
},
},
{
id: "tc",
name: "More table changes",
before: {
type: "doc",
content: [
{
type: "heading",
attrs: {
level: 1,
},
content: [
{
text: "Perf:",
type: "text",
},
],
},
{
type: "paragraph",
content: [
{
text: "Code that can be written to run faster.",
type: "text",
},
],
},
{
type: "table",
content: [
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Rule name",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Source",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Default",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Fixable?",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "no-await-in-loop",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "eslint",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "prefer-set-has",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "unicorn",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "\ud83d\udee0\ufe0f",
type: "text",
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "no-map-spread",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "oxc",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "\ud83d\udee0\ufe0f\ud83d\udca1",
type: "text",
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "no-array-index-key",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "react",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
],
},
],
},
{
type: "paragraph",
},
],
},
after: {
type: "doc",
content: [
{
type: "heading",
attrs: {
level: 1,
},
content: [
{
text: "Perf:",
type: "text",
},
],
},
{
type: "paragraph",
content: [
{
text: "Code that can be written to run faster.",
type: "text",
},
],
},
{
type: "table",
content: [
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Rule name",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Source",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Default",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "Fixable?",
type: "text",
marks: [
{
type: "strong",
attrs: {},
},
],
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "jsx-no-jsx-as-prop",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "react-perf",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "no-map-spread",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "oxc",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "\ud83d\udee0\ufe0f",
type: "text",
},
{
type: "emoji",
attrs: {
"data-name": "bulb",
},
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "prefer-array-find",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "unicorn",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
type: "emoji",
attrs: {
"data-name": "construction",
},
},
],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "prefer-set-has",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
text: "unicorn",
type: "text",
},
],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
},
content: [
{
type: "paragraph",
content: [
{
type: "emoji",
attrs: {
"data-name": "warning",
},
},
{
type: "emoji",
attrs: {
"data-name": "hammer_and_wrench",
},
},
],
},
],
},
],
},
],
},
{
type: "paragraph",
},
],
},
},
{
id: "table-add-row",
name: "Table: add row",
@@ -8,6 +8,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import { parseReactionShorthand } from "@shared/editor/lib/emoji";
import type { ProsemirrorData } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation, CommentValidation } from "@shared/validations";
@@ -157,6 +158,30 @@ function CommentForm({
return;
}
// "+:emoji:" shorthand: react to the comment above instead of replying.
if (thread && !thread.isNew) {
const emoji = parseReactionShorthand(draft);
if (emoji) {
const target = comments
.inThread(thread.id)
.filter((comment) => !comment.isNew)
.pop();
if (target) {
onSaveDraft(undefined);
setForceRender((s) => ++s);
void target.addReaction({ emoji, user });
onSubmit?.();
// re-focus the comment editor
setTimeout(() => {
editorRef.current?.focusAtStart();
}, 0);
return;
}
}
}
const commentDraft = draft;
onSaveDraft(undefined);
setForceRender((s) => ++s);
+17 -4
View File
@@ -11,11 +11,24 @@ import type { Props as ImageUploadProps } from "./ImageUpload";
import ImageUpload from "./ImageUpload";
type Props = ImageUploadProps & {
/** The model whose avatar is displayed and updated by this input. */
model: IAvatar;
/** Alt text for the avatar image. */
alt: string;
/**
* Whether to render the inline "Remove" button when the model has an
* existing avatar. Defaults to true.
*/
showRemoveOption?: boolean;
};
export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
export default function ImageInput({
model,
onSuccess,
alt,
showRemoveOption = true,
...rest
}: Props) {
const { t } = useTranslation();
return (
@@ -29,7 +42,7 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
<Avatar
model={model}
size={AvatarSize.Upload}
variant={AvatarVariant.Square}
variant={AvatarVariant.Round}
alt={alt}
/>
<Flex auto align="center" justify="center" className="upload">
@@ -37,7 +50,7 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
</Flex>
</ImageUpload>
</ImageBox>
{model.avatarUrl && (
{model.avatarUrl && showRemoveOption && (
<Button onClick={() => onSuccess(null)} neutral>
{t("Remove")}
</Button>
@@ -55,7 +68,7 @@ const ImageBox = styled(Flex)`
${avatarStyles};
position: relative;
font-size: 14px;
border-radius: 8px;
border-radius: 50%;
box-shadow: 0 0 0 1px ${s("backgroundSecondary")};
background: ${s("background")};
overflow: hidden;
+1 -1
View File
@@ -49,7 +49,7 @@ const canonicalOrigin = canonicalUrl
: window.location.origin;
type PathParams = {
shareId: string;
shareId?: string;
collectionSlug?: string;
documentSlug?: string;
};
+6
View File
@@ -264,6 +264,12 @@ class UiStore {
@computed
get activeCollectionId(): string | undefined {
// Derive from the active document so it resolves even if the collection
// loads after the document became active.
const activeDocument = this.getPrimaryActiveModel<Document>(Document);
if (activeDocument?.isActive && activeDocument.collectionId) {
return activeDocument.collectionId;
}
return this.getPrimaryActiveModel<Collection>(Collection)?.id;
}
+23 -19
View File
@@ -95,6 +95,7 @@
"@radix-ui/react-one-time-password-field": "^0.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toolbar": "^1.1.11",
@@ -103,7 +104,7 @@
"@sentry/node": "^7.120.4",
"@sentry/react": "^7.120.4",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.2.3",
"@simplewebauthn/server": "^13.3.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@types/form-data": "^2.5.2",
@@ -114,6 +115,7 @@
"addressparser": "^1.0.1",
"async-sema": "^3.1.1",
"bull": "^4.16.5",
"chrono-node": "^2.9.1",
"class-validator": "^0.15.1",
"command-score": "^0.1.2",
"compressorjs": "^1.3.0",
@@ -213,7 +215,7 @@
"react": "^17.0.2",
"react-avatar-editor": "^13.0.2",
"react-colorful": "^5.7.0",
"react-day-picker": "^8.10.1",
"react-day-picker": "^8.10.2",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^17.0.2",
@@ -223,6 +225,7 @@
"react-i18next": "^12.3.1",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.3.0",
"react-remove-scroll": "^2.7.2",
"react-router-dom": "^5.3.4",
"react-use-measure": "^2.1.7",
"react-virtualized-auto-sizer": "^1.0.26",
@@ -235,7 +238,7 @@
"resolve-path": "^1.4.0",
"sanitize-filename": "^1.6.4",
"scroll-into-view-if-needed": "^3.1.0",
"semver": "^7.7.4",
"semver": "^7.8.1",
"sequelize": "^6.37.8",
"sequelize-cli": "^6.6.5",
"sequelize-encrypted": "^1.0.0",
@@ -272,17 +275,17 @@
"y-protocols": "^1.0.7",
"yauzl": "^3.3.1",
"yazl": "^3.3.1",
"yjs": "^13.6.30",
"zod": "^4.3.6"
"yjs": "^13.6.31",
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.28.6",
"@babel/plugin-proposal-decorators": "^7.28.6",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/cli": "^7.29.7",
"@babel/core": "^7.29.7",
"@babel/plugin-proposal-decorators": "^7.29.7",
"@babel/plugin-transform-class-properties": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
"@swc/core": "^1.15.32",
@@ -349,14 +352,14 @@
"@types/validator": "^13.15.10",
"@types/yauzl": "^2.10.3",
"@types/yazl": "^2.4.6",
"@vitest/ui": "^4.1.6",
"@vitest/ui": "^4.1.8",
"babel-plugin-module-resolver": "^5.0.3",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
"babel-plugin-transform-typescript-metadata": "^0.4.0",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.38.46",
"discord-api-types": "^0.38.48",
"husky": "^8.0.3",
"i18next-parser": "^9.4.0",
"ioredis-mock": "^8.13.1",
@@ -366,14 +369,14 @@
"oxlint": "1.66.0",
"oxlint-tsgolint": "0.22.1",
"postinstall-postinstall": "^2.1.0",
"prettier": "^3.6.2",
"prettier": "^3.8.3",
"react-refresh": "^0.18.0",
"rimraf": "^6.1.3",
"rollup-plugin-webpack-stats": "2.1.11",
"terser": "^5.48.0",
"typescript": "^5.9.3",
"unplugin-swc": "^1.5.9",
"vite-plugin-babel": "^1.6.0",
"vite-plugin-babel": "^1.7.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.5"
},
@@ -382,14 +385,15 @@
"@types/koa": "2.15.1",
"prosemirror-transform": "1.10.5",
"prismjs": "1.30.0",
"zod": "^4.3.6",
"zod": "^4.4.3",
"@types/markdown-it": "14.1.2",
"ip-address@npm:10.1.0": "^10.2.0",
"minimatch@npm:9.0.1": "9.0.9",
"lodash@npm:4.17.21": "^4.18.1",
"i18next-parser/i18next": "^23.16.8",
"ws@npm:~8.17.1": "^8.20.1"
"ws@npm:~8.17.1": "^8.20.1",
"uuid": "^11.1.1"
},
"version": "1.7.1",
"version": "1.8.1",
"packageManager": "yarn@4.11.0"
}
+27
View File
@@ -102,6 +102,32 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
const user =
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
// The mail and userPrincipalName values come from the directory via the
// Graph API and are owned by the organization, so an email sourced from
// them is inherently trusted. Microsoft's mutable `email` token claim is
// only trusted when a verification claim confirms it — xms_edov for
// workforce tenants, or the standard email_verified claim in External ID
// / OIDC scenarios.
// https://learn.microsoft.com/en-us/entra/identity-platform/reference-claims-customization
const directoryEmails = [
profileResponse.mail,
profileResponse.userPrincipalName,
]
.filter(Boolean)
.map((value) => value.toLowerCase());
const verificationClaims = [
profile.xms_edov,
profile.email_verified,
].filter((claim) => claim !== undefined);
const emailVerified =
directoryEmails.includes(email.toLowerCase()) ||
(verificationClaims.length
? verificationClaims.some(
(claim) => claim === true || claim === "true"
)
: undefined);
const domain = parseEmail(email).domain;
const subdomain = slugifyDomain(domain);
@@ -121,6 +147,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
user: {
name: profile.name,
email,
emailVerified,
avatarUrl: profile.picture,
},
authenticationProvider: {
+1
View File
@@ -201,6 +201,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
},
user: {
email,
emailVerified: profile.verified,
name: userName,
language,
avatarUrl: userAvatarUrl,
+22 -9
View File
@@ -68,16 +68,18 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
try {
// "domain" is the Google Workspaces domain
const domain = profile._json.hd;
const team = await getTeamFromContext(context);
let team = await getTeamFromContext(context);
const client = getClientFromOAuthState(context);
const user =
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
// No profile domain means personal gmail account
// No team implies the request came from the apex domain
// This combination is always an error
// No profile domain means a personal gmail account, and no team means
// the request came from the apex domain rather than a workspace
// subdomain. We can't infer the workspace from the domain, so resolve
// it from the verified email's existing accounts instead.
if (!domain && !team) {
const userExists = await User.count({
const existingAccounts = await User.findAll({
attributes: ["id", "teamId"],
where: { email: profile.email.toLowerCase() },
include: [
{
@@ -86,14 +88,23 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
},
],
});
const teamIds = new Set(
existingAccounts.map((account) => account.teamId)
);
// Users cannot create a team with personal gmail accounts
if (!userExists) {
// A personal gmail account cannot be used to create a new workspace.
if (teamIds.size === 0) {
throw GmailAccountCreationError();
}
// To log-in with a personal account, users must specify a team subdomain
throw TeamDomainRequiredError();
// When the email belongs to more than one workspace it is ambiguous
// which to sign into, so the user must start from its subdomain.
if (teamIds.size > 1) {
throw TeamDomainRequiredError();
}
// Belongs to exactly one workspace — resolve it and sign in there.
team = existingAccounts[0].team;
}
// remove the TLD and form a subdomain from the remaining
@@ -127,6 +138,8 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
},
user: {
email: profile.email,
// Google only returns confirmed workspace email addresses.
emailVerified: true,
name: profile.displayName,
language,
avatarUrl,
+2 -2
View File
@@ -148,9 +148,9 @@ export class Linear {
switch (resource.type) {
case UnfurlResourceType.Issue:
return Linear.unfurlIssue(client, resource.id, actor);
return await Linear.unfurlIssue(client, resource.id, actor);
case UnfurlResourceType.Project:
return Linear.unfurlProject(client, resource.id, actor);
return await Linear.unfurlProject(client, resource.id, actor);
default:
return;
}
+11
View File
@@ -105,6 +105,7 @@ export function createOIDCRouter(
return decoded as {
email?: string;
email_verified?: boolean | string;
preferred_username?: string;
sub?: string;
};
@@ -122,6 +123,15 @@ export function createOIDCRouter(
);
}
// The email_verified claim is part of the OIDC standard claims.
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
const emailVerifiedClaim =
profile.email_verified ?? token.email_verified;
const emailVerified =
emailVerifiedClaim === undefined
? undefined
: emailVerifiedClaim === true || emailVerifiedClaim === "true";
const team = await getTeamFromContext(context);
const client = getClientFromOAuthState(context);
const user =
@@ -206,6 +216,7 @@ export function createOIDCRouter(
user: {
name,
email,
emailVerified,
avatarUrl,
},
authenticationProvider: {
+2
View File
@@ -110,6 +110,8 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
user: {
name: profile.user.name,
email: profile.user.email,
// Slack only returns confirmed workspace email addresses.
emailVerified: true,
avatarUrl: profile.user.image_192,
},
authenticationProvider: {
+4
View File
@@ -44,6 +44,10 @@ router.post(
const { key } = ctx.input.body;
const file = ctx.input.file;
if (!file) {
throw ValidationError("Request must include a file parameter");
}
const attachment = await Attachment.findOne({
where: { key },
rejectOnEmpty: true,
+1 -1
View File
@@ -10,7 +10,7 @@ export const FilesCreateSchema = z.object({
.refine(ValidateKey.isValid, { message: ValidateKey.message })
.transform(ValidateKey.sanitize),
}),
file: z.custom<formidable.File>(),
file: z.custom<formidable.File>().optional(),
});
export type FilesCreateReq = z.infer<typeof FilesCreateSchema>;
@@ -7,6 +7,7 @@ import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { randomString } from "@shared/random";
import { TeamPreference } from "@shared/types";
import { WebhookSubscriptionValidation } from "@shared/validations";
import type WebhookSubscription from "~/models/WebhookSubscription";
import Button from "~/components/Button";
import Input from "~/components/Input";
@@ -229,6 +230,7 @@ function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) {
required
flex
pattern={isCloudHosted ? "https://.*" : "https?://.*"}
maxLength={WebhookSubscriptionValidation.maxUrlLength}
placeholder="https://…"
label={t("URL")}
error={
@@ -238,7 +240,10 @@ function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) {
)
: undefined
}
{...register("url", { required: true })}
{...register("url", {
required: true,
maxLength: WebhookSubscriptionValidation.maxUrlLength,
})}
/>
<Input
flex
+4
View File
@@ -1,10 +1,14 @@
import { z } from "zod";
import { WebhookSubscriptionValidation } from "@shared/validations";
import env from "@server/env";
import { WebhookSubscription } from "@server/models";
import { BaseSchema } from "@server/routes/api/schema";
const webhookUrl = z
.url()
.max(WebhookSubscriptionValidation.maxUrlLength, {
error: `Webhook url must be ${WebhookSubscriptionValidation.maxUrlLength} characters or less`,
})
.refine((val) => !env.isCloudHosted || val.startsWith("https://"), {
error: "Webhook url must use https",
});
@@ -86,4 +86,51 @@ describe("WebhookProcessor", () => {
subscriptionId: subscriptionTwo.id,
});
});
describe("shouldQueue", () => {
it("returns true when a matching subscription exists", async () => {
const subscription = await buildWebhookSubscription({
url: "http://example.com",
events: ["users"],
});
const event: UserEvent = {
name: "users.signin",
userId: subscription.createdById,
teamId: subscription.teamId,
actorId: subscription.createdById,
ip,
};
expect(await WebhookProcessor.shouldQueue(event)).toBe(true);
});
it("returns false when no subscription matches the event", async () => {
const subscription = await buildWebhookSubscription({
url: "http://example.com",
events: ["documents.create"],
});
const event: UserEvent = {
name: "users.signin",
userId: subscription.createdById,
teamId: subscription.teamId,
actorId: subscription.createdById,
ip,
};
expect(await WebhookProcessor.shouldQueue(event)).toBe(false);
});
it("returns false when the team has no subscriptions", async () => {
const user = await buildUser();
const event: UserEvent = {
name: "users.signin",
userId: user.id,
teamId: user.teamId,
actorId: user.id,
ip,
};
expect(await WebhookProcessor.shouldQueue(event)).toBe(false);
});
});
});
@@ -6,20 +6,39 @@ import DeliverWebhookTask from "../tasks/DeliverWebhookTask";
export default class WebhookProcessor extends BaseProcessor {
static applicableEvents: ["*"] = ["*"];
/**
* Only queue an event when the team has an enabled webhook subscription that
* matches it. The vast majority of events belong to teams with no applicable
* subscriptions, so this avoids creating and running an empty job for them.
*
* @param event The event about to be queued.
* @returns true if a matching subscription exists.
*/
static async shouldQueue(event: Event): Promise<boolean> {
if (!event.teamId) {
return false;
}
const subscriptions = await WebhookSubscription.findEnabledByTeamId(
event.teamId
);
return subscriptions.some((subscription) =>
WebhookSubscription.matchEvent(subscription.events, event.name)
);
}
async perform(event: Event) {
if (!event.teamId) {
return;
}
const webhookSubscriptions = await WebhookSubscription.findAll({
where: {
enabled: true,
teamId: event.teamId,
},
});
const subscriptions = await WebhookSubscription.findEnabledByTeamId(
event.teamId
);
const applicableSubscriptions = webhookSubscriptions.filter((webhook) =>
webhook.validForEvent(event)
const applicableSubscriptions = subscriptions.filter((subscription) =>
WebhookSubscription.matchEvent(subscription.events, event.name)
);
await Promise.all(
@@ -1,3 +1,4 @@
import { FetchError } from "node-fetch";
import {
http,
HttpResponse,
@@ -12,7 +13,9 @@ import {
buildWebhookSubscription,
} from "@server/test/factories";
import type { UserEvent } from "@server/types";
import DeliverWebhookTask from "./DeliverWebhookTask";
import DeliverWebhookTask, {
isExpectedNetworkError,
} from "./DeliverWebhookTask";
const ip = "127.0.0.1";
@@ -243,3 +246,63 @@ describe("DeliverWebhookTask", () => {
expect(delivery.responseBody).toEqual('{"message":"Failure"}');
});
});
describe("isExpectedNetworkError", () => {
test("treats node-fetch FetchError as expected", () => {
expect(
isExpectedNetworkError(
new FetchError("request to https://example.com failed", "system")
)
).toBe(true);
});
test("treats raw socket errors as expected", () => {
expect(isExpectedNetworkError(new Error("socket hang up"))).toBe(true);
expect(
isExpectedNetworkError(
Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" })
)
).toBe(true);
});
test("treats connection error codes as expected", () => {
for (const code of [
"ECONNREFUSED",
"ETIMEDOUT",
"EHOSTUNREACH",
"ENOTFOUND",
"EAI_AGAIN",
]) {
expect(
isExpectedNetworkError(Object.assign(new Error("boom"), { code }))
).toBe(true);
}
});
test("treats invalid certificate errors as expected", () => {
expect(
isExpectedNetworkError(
Object.assign(new Error("self signed certificate"), {
code: "DEPTH_ZERO_SELF_SIGNED_CERT",
})
)
).toBe(true);
});
test("treats the request timeout thrown by the fetch wrapper as expected", () => {
expect(
isExpectedNetworkError(new Error("Request timeout after 5000ms"))
).toBe(true);
});
test("does not treat unrelated errors as expected", () => {
expect(
isExpectedNetworkError(new TypeError("undefined is not a function"))
).toBe(false);
expect(
isExpectedNetworkError(new Error("Cannot read property foo of undefined"))
).toBe(false);
expect(isExpectedNetworkError("socket hang up")).toBe(false);
expect(isExpectedNetworkError(undefined)).toBe(false);
});
});
@@ -78,6 +78,56 @@ function assertUnreachable(event: never) {
Logger.warn(`DeliverWebhookTask did not handle ${(event as Event).name}`);
}
/**
* Node connection-level error codes that are expected when delivering to
* arbitrary, user-supplied webhook URLs. These indicate a misconfigured or
* unreachable destination rather than a bug in Outline.
*/
const expectedNetworkErrorCodes = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ECONNABORTED",
"ETIMEDOUT",
"EHOSTUNREACH",
"ENETUNREACH",
"ENOTFOUND",
"EAI_AGAIN",
"EPIPE",
"EPROTO",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"SELF_SIGNED_CERT_IN_CHAIN",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"CERT_HAS_EXPIRED",
"ERR_TLS_CERT_ALTNAME_INVALID",
]);
/**
* Determine whether an error thrown while delivering a webhook is an expected
* network failure caused by the user-supplied destination URL (connection
* reset, timeout, unreachable host, invalid certificate, etc) rather than an
* unexpected bug. Such failures are noisy and do not need error tracking.
*
* @param err The error that occurred during delivery.
* @returns true if the error is an expected network failure.
*/
export function isExpectedNetworkError(err: unknown): boolean {
if (err instanceof FetchError) {
return true;
}
if (err instanceof Error) {
const code = (err as NodeJS.ErrnoException).code;
if (code && expectedNetworkErrorCodes.has(code)) {
return true;
}
// node-fetch surfaces some low-level socket failures (and our fetch wrapper
// converts aborted requests into timeouts) without a structured code.
return /socket hang up|request timeout|network|ECONNRESET/i.test(
err.message
);
}
return false;
}
type Props = {
subscriptionId: string;
event: Event;
@@ -765,7 +815,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
});
status = response.ok ? "success" : "failed";
} catch (err) {
if (err instanceof FetchError && env.isCloudHosted) {
if (isExpectedNetworkError(err) && env.isCloudHosted) {
Logger.warn(`Failed to send webhook: ${err.message}`, {
event,
deliveryId: delivery.id,
@@ -115,6 +115,7 @@ describe("accountProvisioner", () => {
user: {
name: userWithoutAuth.name,
email,
emailVerified: true,
avatarUrl: userWithoutAuth.avatarUrl,
},
team: {
@@ -138,6 +139,54 @@ describe("accountProvisioner", () => {
expect(isNewUser).toEqual(false);
});
it("should not allow authentication by email matching when email is unverified", async () => {
const subdomain = faker.internet.domainWord();
const existingTeam = await buildTeam({
subdomain,
});
const providers = await existingTeam.$get("authenticationProviders");
const authenticationProvider = providers[0];
const email = faker.internet.email();
const userWithoutAuth = await buildUser({
email,
teamId: existingTeam.id,
authentications: [],
});
let error;
try {
await accountProvisioner(ctx, {
user: {
name: userWithoutAuth.name,
email,
emailVerified: false,
avatarUrl: userWithoutAuth.avatarUrl,
},
team: {
teamId: existingTeam.id,
name: existingTeam.name,
avatarUrl: existingTeam.avatarUrl,
subdomain,
},
authenticationProvider: {
name: authenticationProvider.name,
providerId: authenticationProvider.providerId,
},
authentication: {
providerId: randomUUID(),
accessToken: "123",
scopes: ["read"],
},
});
} catch (err) {
error = err;
}
expect(error).toBeTruthy();
expect(error.id).toEqual("invalid_authentication");
});
it("should throw an error when authentication provider is disabled", async () => {
const existingTeam = await buildTeam();
const providers = await existingTeam.$get("authenticationProviders");
@@ -250,6 +299,7 @@ describe("accountProvisioner", () => {
user: {
name: "Jenny Tester",
email,
emailVerified: true,
avatarUrl: faker.image.avatar(),
},
team: {
@@ -291,6 +341,7 @@ describe("accountProvisioner", () => {
user: {
name: "Jenny Tester",
email,
emailVerified: true,
avatarUrl: faker.image.avatar(),
},
team: {
+7
View File
@@ -17,6 +17,7 @@ import {
Event,
Team,
} from "@server/models";
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { sequelize } from "@server/storage/database";
import { PluginManager } from "@server/utils/PluginManager";
@@ -34,6 +35,8 @@ type Props = {
name: string;
/** The email address of the user */
email: string;
/** Whether the provider has verified the user owns the email address */
emailVerified?: boolean;
/** The public url of an image representing the user */
avatarUrl?: string | null;
/** The language of the user, if known */
@@ -178,6 +181,10 @@ async function accountProvisioner(
result = await userProvisioner(ctx, {
name: userParams.name,
email: userParams.email,
emailVerified: userParams.emailVerified,
authenticationProviderName: AuthenticationHelper.getProviderName(
authenticationProviderParams.name
),
language: userParams.language,
role: isNewTeam ? UserRole.Admin : undefined,
avatarUrl: userParams.avatarUrl,
@@ -0,0 +1,79 @@
import { Node } from "prosemirror-model";
import { prosemirrorToYDoc } from "y-prosemirror";
import { schema } from "@server/editor";
import { buildDocument, buildUser } from "@server/test/factories";
import documentCollaborativeUpdater from "./documentCollaborativeUpdater";
describe("documentCollaborativeUpdater", () => {
const buildYDoc = (content: object[]) => {
const doc = Node.fromJSON(schema, { type: "doc", content });
return prosemirrorToYDoc(doc, "default");
};
it("persists canonical JSON without empty attrs on marks", async () => {
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
});
const ydoc = buildYDoc([
{
type: "paragraph",
content: [
{
type: "text",
text: "Deciders:",
marks: [{ type: "strong" }],
},
],
},
]);
await documentCollaborativeUpdater({
documentId: document.id,
ydoc,
sessionCollaboratorIds: [user.id],
isLastConnection: true,
clientVersion: null,
});
await document.reload();
const marks = JSON.stringify(document.content).match(/"attrs":\{\}/g);
expect(marks).toBeNull();
const text = document.content?.content?.[0]?.content?.[0];
expect(text?.marks).toEqual([{ type: "strong" }]);
});
it("does not persist when content is unchanged", async () => {
const user = await buildUser();
const content = [
{
type: "paragraph",
content: [{ type: "text", text: "Hello" }],
},
];
const ydoc = buildYDoc(content);
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
content: Node.fromJSON(schema, { type: "doc", content }).toJSON(),
});
const updatedAt = document.updatedAt;
await documentCollaborativeUpdater({
documentId: document.id,
ydoc,
sessionCollaboratorIds: [user.id],
isLastConnection: true,
clientVersion: null,
});
await document.reload();
expect(document.updatedAt).toEqual(updatedAt);
});
});
@@ -1,8 +1,10 @@
import isEqual from "fast-deep-equal";
import { uniq } from "es-toolkit/compat";
import { Node } from "prosemirror-model";
import { yDocToProsemirrorJSON } from "y-prosemirror";
import * as Y from "yjs";
import type { ProsemirrorData } from "@shared/types";
import { schema } from "@server/editor";
import Logger from "@server/logging/Logger";
import { Document, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
@@ -50,7 +52,14 @@ export default async function documentCollaborativeUpdater({
});
const state = Y.encodeStateAsUpdate(ydoc);
const content = yDocToProsemirrorJSON(ydoc, "default") as ProsemirrorData;
// Round-trip through the schema so the stored JSON is canonical. The raw
// y-prosemirror output includes empty `attrs: {}` on every mark, and outputs
// properties in a different order - resulting in spurious "edits"
const content = Node.fromJSON(
schema,
yDocToProsemirrorJSON(ydoc, "default")
).toJSON() as ProsemirrorData;
const isUnchanged = isEqual(document.content, content);
const isDeleted = !!document.deletedAt;
const lastModifiedById = isDeleted
+1 -1
View File
@@ -24,7 +24,7 @@ async function documentMover(
ctx: APIContext,
{
document,
collectionId = null,
collectionId,
parentDocumentId = null,
// convert undefined to null so parentId comparison treats them as equal
index,
+98
View File
@@ -0,0 +1,98 @@
import { traceFunction } from "@server/logging/tracing";
import { ValidationError } from "@server/errors";
import { Collection, Revision } from "@server/models";
import type { Document } from "@server/models";
import { authorize } from "@server/policies";
import type { APIContext } from "@server/types";
import { assertPresent } from "@server/validation";
type Props = {
/** The document to restore. Must be loaded with `paranoid: false`. */
document: Document;
/** Destination collection to restore into. Defaults to the original collection. */
collectionId?: string | null;
/** Revision to restore the document's content from, when not archived or deleted. */
revisionId?: string | null;
};
/**
* Restores a previously archived or deleted document, or restores a document's
* content to a specific revision. Re-attaches the document to the destination
* collection's structure when applicable and authorizes the acting user.
*
* @param ctx - the API context, providing the acting user and transaction.
* @param props - the document and restore options.
* @returns the restored document.
* @throws ValidationError if the destination collection is not active.
*/
async function documentRestorer(
ctx: APIContext,
{ document, collectionId, revisionId }: Props
): Promise<Document> {
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const sourceCollectionId = document.collectionId;
const destCollectionId = collectionId ?? sourceCollectionId;
const srcCollection = sourceCollectionId
? await Collection.findByPk(sourceCollectionId, {
userId: user.id,
includeDocumentStructure: true,
paranoid: false,
transaction,
})
: undefined;
const destCollection = destCollectionId
? await Collection.findByPk(destCollectionId, {
userId: user.id,
includeDocumentStructure: true,
transaction,
})
: undefined;
if (!destCollection?.isActive) {
throw ValidationError(
"Unable to restore, the collection may have been deleted or archived"
);
}
if (sourceCollectionId && sourceCollectionId !== destCollection.id) {
authorize(user, "updateDocument", srcCollection);
await srcCollection?.removeDocumentInStructure(document, {
save: true,
transaction,
});
}
if (document.deletedAt) {
authorize(user, "restore", document);
authorize(user, "updateDocument", destCollection);
// restore a previously deleted document
await document.restoreTo(ctx, { collectionId: destCollection.id });
} else if (document.archivedAt) {
authorize(user, "unarchive", document);
authorize(user, "updateDocument", destCollection);
// restore a previously archived document
await document.restoreTo(ctx, { collectionId: destCollection.id });
} else if (revisionId) {
// restore a document to a specific revision
authorize(user, "update", document);
const revision = await Revision.findByPk(revisionId, { transaction });
authorize(document, "restore", revision);
await document.restoreFromRevision(revision);
await document.saveWithCtx(ctx, undefined, { name: "restore" });
} else {
assertPresent(revisionId, "revisionId is required");
}
return document;
}
export default traceFunction({
spanName: "documentRestorer",
})(documentRestorer);
+78
View File
@@ -3,6 +3,7 @@ import * as Y from "yjs";
import { TextEditMode } from "@shared/types";
import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension";
import { Event } from "@server/models";
import { parser } from "@server/editor";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { buildDocument, buildUser } from "@server/test/factories";
@@ -1481,5 +1482,82 @@ describe("documentUpdater", () => {
"Second item"
);
});
it("should apply a link when wrapping existing table cell text", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
document.content = parser
.parse("| Name | Notes |\n|------|-------|\n| Alpha | see |\n")
.toJSON();
await document.save();
const result = DocumentHelper.applyMarkdownToDocument(
document,
"[see](https://example.com/docs)",
TextEditMode.Patch,
"see"
);
// The cell text is unchanged but should now carry a link mark — it must
// not be silently dropped during the merge.
const cellText =
result.content!.content![0].content![1].content![1].content![0]
.content![0];
expect(cellText.text).toEqual("see");
expect(cellText.marks).toEqual([
expect.objectContaining({
type: "link",
attrs: expect.objectContaining({ href: "https://example.com/docs" }),
}),
]);
});
it("should preserve other table cells when adding a link to one cell", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
document.content = parser
.parse(
"| Name | Notes |\n|------|-------|\n| Alpha | see |\n| Beta | other |\n"
)
.toJSON();
await document.save();
// Capture the untouched (Beta) row BEFORE patching so we can assert its
// structure and attrs are preserved exactly, not just its text.
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const untouchedRow = beforeDoc.content[0].content[2];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"see [docs](https://example.com/d)",
TextEditMode.Patch,
"see"
);
// The patched cell gained the link
expect(result.text).toContain("[docs](https://example.com/d)");
// The untouched row node must remain identical
expect(result.content!.content![0].content![2]).toEqual(untouchedRow);
});
it("should apply a mark when wrapping existing list item text", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
document.content = parser.parse("- clickme\n- other\n").toJSON();
await document.save();
const result = DocumentHelper.applyMarkdownToDocument(
document,
"[clickme](https://example.com)",
TextEditMode.Patch,
"clickme"
);
expect(result.text).toContain("[clickme](https://example.com)");
expect(result.text).toContain("other");
});
});
});
+65
View File
@@ -57,6 +57,7 @@ describe("userProvisioner", () => {
const result = await userProvisioner(ctx, {
name: existing.name,
email,
emailVerified: true,
avatarUrl: existing.avatarUrl,
teamId: existing.teamId,
authentication: {
@@ -77,6 +78,34 @@ describe("userProvisioner", () => {
expect(isNewUser).toEqual(false);
});
it("should not match an existing user by email when email is unverified", async () => {
const team = await buildTeam();
const teamAuthProviders = await team.$get("authenticationProviders");
const authenticationProvider = teamAuthProviders[0];
const email = "mynam@email.com";
await buildUser({
email,
teamId: team.id,
authentications: [],
});
await expect(
userProvisioner(ctx, {
name: "Imposter",
email,
emailVerified: false,
teamId: team.id,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: randomUUID(),
accessToken: "123",
scopes: ["read"],
},
})
).rejects.toThrow("has not been verified by");
});
it("should add authentication provider to invited users", async () => {
const team = await buildTeam({ inviteRequired: true });
const teamAuthProviders = await team.$get("authenticationProviders");
@@ -91,6 +120,7 @@ describe("userProvisioner", () => {
const result = await userProvisioner(ctx, {
name: existing.name,
email,
emailVerified: true,
avatarUrl: existing.avatarUrl,
teamId: existing.teamId,
authentication: {
@@ -264,6 +294,7 @@ describe("userProvisioner", () => {
const result = await userProvisioner(ctx, {
name: invite.name,
email: "invite@ExamPle.com",
emailVerified: true,
teamId: invite.teamId,
authentication: {
authenticationProviderId: authenticationProvider.id,
@@ -295,6 +326,7 @@ describe("userProvisioner", () => {
const result = await userProvisioner(ctx, {
name: invite.name,
email: "external@ExamPle.com", // ensure that email is case insensistive
emailVerified: true,
teamId: invite.teamId,
});
const { user, authentication, isNewUser } = result;
@@ -340,6 +372,7 @@ describe("userProvisioner", () => {
const result = await userProvisioner(ctx, {
name: faker.person.fullName(),
email,
emailVerified: true,
teamId: team.id,
authentication: {
authenticationProviderId: authenticationProvider.id,
@@ -357,6 +390,36 @@ describe("userProvisioner", () => {
expect(isNewUser).toEqual(true);
});
it("should reject an unverified email when the team has allowed domains", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const domain = faker.internet.domainName();
await TeamDomain.create({
teamId: team.id,
name: domain,
createdById: admin.id,
});
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
const email = faker.internet.email({ provider: domain });
await expect(
userProvisioner(ctx, {
name: faker.person.fullName(),
email,
emailVerified: false,
teamId: team.id,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: "fake-service-id",
accessToken: "123",
scopes: ["read"],
},
})
).rejects.toThrow("has not been verified by");
});
it("should create a user from allowed domain with emailMatchOnly", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
@@ -372,6 +435,7 @@ describe("userProvisioner", () => {
const result = await userProvisioner(ctx, {
name: "Test Name",
email,
emailVerified: true,
teamId: team.id,
});
const { user, authentication, isNewUser } = result;
@@ -408,6 +472,7 @@ describe("userProvisioner", () => {
userProvisioner(ctx, {
name: "Bad Domain User",
email: faker.internet.email(),
emailVerified: true,
teamId: team.id,
authentication: {
authenticationProviderId: authenticationProvider.id,
+26 -1
View File
@@ -25,6 +25,13 @@ type Props = {
name: string;
/** The email address of the user */
email: string;
/**
* Whether the provider has verified the user owns the email address.
* Matching an existing account by email only happens when explicitly true.
*/
emailVerified?: boolean;
/** The display name of the authentication provider, eg "Google". */
authenticationProviderName?: string;
/** The language of the user, if known */
language?: string;
/** The role for new user, Member if none is provided */
@@ -54,7 +61,17 @@ type Props = {
export default async function userProvisioner(
ctx: APIContext,
{ name, email, role, language, avatarUrl, teamId, authentication }: Props
{
name,
email,
emailVerified,
authenticationProviderName,
role,
language,
avatarUrl,
teamId,
authentication,
}: Props
): Promise<UserProvisionerResult> {
const auth = authentication
? await UserAuthentication.findOne({
@@ -135,6 +152,14 @@ export default async function userProvisioner(
attributes: ["defaultUserRole", "inviteRequired", "id"],
});
// Unverified emails cannot match an existing account or pass allow listed domains
if (emailVerified !== true && (existingUser || team?.allowedDomains.length)) {
const providerName = authenticationProviderName ?? "your identity provider";
throw InvalidAuthenticationError(
`Your email address has not been verified by ${providerName}. Please verify your email and try signing in again.`
);
}
// We have an existing user, so we need to update it with our
// new details and count this as a new user creation.
if (existingUser) {
+84
View File
@@ -12,17 +12,37 @@ import { baseStyles } from "./templates/components/EmailLayout";
const useTestEmailService = env.isDevelopment && !env.SMTP_USERNAME;
type SendMailOptions = {
/** The email address being sent to. */
to: string;
/** The address the email is sent from. */
from: EmailAddress;
/** An address to set as the reply-to for the email. */
replyTo?: string;
/** A unique identifier for the message, used for threading. */
messageId?: string;
/** Message IDs this email is a reply to, used for threading. */
references?: string[];
/** The subject line of the email. */
subject: string;
/** Preview text shown in email client list views. */
previewText?: string;
/** The plain-text version of the email body. */
text: string;
/** The React element rendered to produce the HTML body. */
component: JSX.Element;
/** Additional CSS to inject into the head of the email. */
headCSS?: string;
/** The URL used to unsubscribe from these emails. */
unsubscribeUrl?: string;
/** Tags used for reporting, where supported by the email provider. */
tags?: EmailTags;
};
type EmailTags = {
/** The broad category of the email, e.g. "notification". */
category: string;
/** The specific template name, e.g. "InviteEmail". */
template: string;
};
/**
@@ -167,6 +187,7 @@ export class Mailer {
references: data.references,
inReplyTo: data.references?.at(-1),
subject: data.subject,
headers: this.tagHeaders(data.tags),
html,
text: data.text,
list: data.unsubscribeUrl
@@ -200,6 +221,69 @@ export class Mailer {
}
};
/**
* Builds the provider-specific headers used to tag a message for reporting.
* Each supported provider expects a different header name and format; for
* providers that do not support tagging, or when no tags are given, no
* headers are returned.
*
* @param tags The tags to apply to the message.
* @returns A map of headers to set on the message, or undefined.
*/
private tagHeaders(
tags?: EmailTags
): Record<string, string | string[]> | undefined {
if (!tags) {
return undefined;
}
// Mailgun: up to three tags via repeated X-Mailgun-Tag headers.
// https://documentation.mailgun.com/docs/mailgun/user-manual/tracking-messages/#tagging
if (this.isMailgun) {
return { "X-Mailgun-Tag": Object.values(tags).slice(0, 3) };
}
// SES: comma-separated name=value pairs via X-SES-MESSAGE-TAGS.
// https://docs.aws.amazon.com/ses/latest/dg/event-publishing-send-email.html
if (this.isSES) {
return {
"X-SES-MESSAGE-TAGS": Object.entries(tags)
.map(([name, value]) => `${name}=${value}`)
.join(", "),
};
}
// Postmark: a single tag per message via X-PM-Tag.
// https://postmarkapp.com/support/article/1117-add-link-tracking-to-a-message
if (this.isPostmark) {
return { "X-PM-Tag": tags.template };
}
return undefined;
}
/** The configured SMTP host and service name, for provider detection. */
private get provider(): string {
return `${env.SMTP_HOST ?? ""} ${env.SMTP_SERVICE ?? ""}`;
}
/** Whether the configured SMTP provider is Mailgun. */
private get isMailgun(): boolean {
return /mailgun/i.test(this.provider);
}
/** Whether the configured SMTP provider is Amazon SES. */
private get isSES(): boolean {
// Detected by the SES SMTP host (email-smtp.<region>.amazonaws.com) or a
// well-known Nodemailer service key (SES, SES-US-EAST-1, etc.).
return /amazonaws|(?:^|\s)ses\b/i.test(this.provider);
}
/** Whether the configured SMTP provider is Postmark. */
private get isPostmark(): boolean {
return /postmark/i.test(this.provider);
}
private getOptions(): SMTPTransport.Options {
// nodemailer will use the service config to determine host/port
if (env.SMTP_SERVICE) {
+2 -1
View File
@@ -177,6 +177,7 @@ export default abstract class BaseEmail<
text: this.renderAsText(data),
headCSS: this.headCSS?.(data),
unsubscribeUrl: this.unsubscribeUrl?.(data),
tags: { category: this.category, template: templateName },
});
Metrics.increment("email.sent", {
templateName,
@@ -334,7 +335,7 @@ export default abstract class BaseEmail<
await ProsemirrorHelper.processMentions(node)
);
let content = ProsemirrorHelper.toHTML(processedNode, {
let content = await ProsemirrorHelper.toHTML(processedNode, {
centered: false,
});
+10
View File
@@ -372,6 +372,16 @@ export class Environment {
@IsOptional()
public PROXY_IP_HEADER = this.toOptionalString(environment.PROXY_IP_HEADER);
/**
* Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
* X-Forwarded-Proto) set by an upstream proxy or load balancer. Defaults to
* true for backwards compat. Set to false if not running behind a proxy in production.
*/
@IsBoolean()
public PROXY_HEADERS_TRUSTED = this.toBoolean(
environment.PROXY_HEADERS_TRUSTED ?? "true"
);
/**
* Should the installation send anonymized statistics to the maintainers.
* Defaults to true.
+6 -3
View File
@@ -184,8 +184,8 @@ async function start(_id: number, disconnect: () => void) {
}
Logger.info("lifecycle", `Starting ${name} service`);
const init = services[name as keyof typeof services];
await init(app, server as https.Server, env.SERVICES);
const { default: init } = await services[name as keyof typeof services]();
await Promise.resolve(init(app, server as https.Server, env.SERVICES));
}
server.on("error", (err) => {
@@ -258,8 +258,11 @@ const isWebProcess =
env.SERVICES.includes("api") ||
env.SERVICES.includes("collaboration");
const isWorkerProcess =
env.SERVICES.length === 1 && env.SERVICES.includes("worker");
void throng({
master,
worker: start,
count: isWebProcess ? webProcessCount : undefined,
count: isWorkerProcess ? 1 : isWebProcess ? webProcessCount : undefined,
});
@@ -0,0 +1,106 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.changeColumn(
"webhook_subscriptions",
"url",
{
type: Sequelize.STRING(1024),
allowNull: false,
},
{ transaction }
);
await queryInterface.changeColumn(
"oauth_clients",
"developerUrl",
{
type: Sequelize.STRING(1024),
allowNull: true,
},
{ transaction }
);
await queryInterface.changeColumn(
"oauth_clients",
"avatarUrl",
{
type: Sequelize.STRING(1024),
allowNull: true,
},
{ transaction }
);
await queryInterface.changeColumn(
"oauth_clients",
"redirectUris",
{
type: Sequelize.ARRAY(Sequelize.STRING(1024)),
allowNull: false,
defaultValue: [],
},
{ transaction }
);
await queryInterface.changeColumn(
"oauth_authorization_codes",
"redirectUri",
{
type: Sequelize.STRING(1024),
allowNull: false,
},
{ transaction }
);
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.changeColumn(
"oauth_authorization_codes",
"redirectUri",
{
type: Sequelize.STRING(255),
allowNull: false,
},
{ transaction }
);
await queryInterface.changeColumn(
"oauth_clients",
"redirectUris",
{
type: Sequelize.ARRAY(Sequelize.STRING(255)),
allowNull: false,
defaultValue: [],
},
{ transaction }
);
await queryInterface.changeColumn(
"oauth_clients",
"avatarUrl",
{
type: Sequelize.STRING(255),
allowNull: true,
},
{ transaction }
);
await queryInterface.changeColumn(
"oauth_clients",
"developerUrl",
{
type: Sequelize.STRING(255),
allowNull: true,
},
{ transaction }
);
await queryInterface.changeColumn(
"webhook_subscriptions",
"url",
{
type: Sequelize.STRING(255),
allowNull: false,
},
{ transaction }
);
});
},
};
@@ -0,0 +1,14 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
await queryInterface.removeColumn("teams", "collaborativeEditing");
},
async down(queryInterface, Sequelize) {
await queryInterface.addColumn("teams", "collaborativeEditing", {
type: Sequelize.BOOLEAN,
});
},
};
+11 -10
View File
@@ -987,16 +987,17 @@ class Document extends ArchivableModel<
const findAllChildDocumentIds = async (
...parentDocumentId: string[]
): Promise<string[]> => {
const childDocuments = await (
this.constructor as typeof Document
).findAll({
attributes: ["id"],
where: {
parentDocumentId,
...where,
},
...options,
});
// Unscoped as this method only ever reads the id column
const childDocuments = await (this.constructor as typeof Document)
.unscoped()
.findAll({
attributes: ["id"],
where: {
parentDocumentId,
...where,
},
...options,
});
const childDocumentIds = childDocuments.map((doc) => doc.id);
+94
View File
@@ -0,0 +1,94 @@
import { buildTeam, buildWebhookSubscription } from "@server/test/factories";
import WebhookSubscription from "./WebhookSubscription";
describe("WebhookSubscription", () => {
describe("matchEvent", () => {
it("matches everything for a wildcard subscription", () => {
expect(WebhookSubscription.matchEvent(["*"], "users.signin")).toBe(true);
});
it("matches an exact event name", () => {
expect(
WebhookSubscription.matchEvent(["users.signin"], "users.signin")
).toBe(true);
});
it("matches a namespace prefix", () => {
expect(WebhookSubscription.matchEvent(["users"], "users.signin")).toBe(
true
);
});
it("does not match unrelated events", () => {
expect(
WebhookSubscription.matchEvent(["documents"], "users.signin")
).toBe(false);
});
});
describe("findEnabledByTeamId", () => {
it("returns only enabled subscriptions for the team", async () => {
const subscription = await buildWebhookSubscription({
events: ["users"],
});
const disabled = await buildWebhookSubscription({
teamId: subscription.teamId,
events: ["documents"],
});
await disabled.disable();
const result = await WebhookSubscription.findEnabledByTeamId(
subscription.teamId
);
expect(result).toHaveLength(1);
expect(result[0].id).toEqual(subscription.id);
expect(result[0].events).toEqual(["users"]);
});
it("returns an empty array when the team has no subscriptions", async () => {
const team = await buildTeam();
const result = await WebhookSubscription.findEnabledByTeamId(team.id);
expect(result).toEqual([]);
});
it("reflects changes after a subscription is disabled", async () => {
const subscription = await buildWebhookSubscription({
events: ["users"],
});
// prime the cache
const before = await WebhookSubscription.findEnabledByTeamId(
subscription.teamId
);
expect(before).toHaveLength(1);
await subscription.disable();
const after = await WebhookSubscription.findEnabledByTeamId(
subscription.teamId
);
expect(after).toHaveLength(0);
});
it("reflects changes after a subscription is destroyed", async () => {
const subscription = await buildWebhookSubscription({
events: ["users"],
});
const before = await WebhookSubscription.findEnabledByTeamId(
subscription.teamId
);
expect(before).toHaveLength(1);
await subscription.destroy();
const after = await WebhookSubscription.findEnabledByTeamId(
subscription.teamId
);
expect(after).toHaveLength(0);
});
});
});
+90 -14
View File
@@ -4,6 +4,7 @@ import type {
InferAttributes,
InferCreationAttributes,
InstanceUpdateOptions,
Transaction,
} from "sequelize";
import {
Column,
@@ -14,12 +15,19 @@ import {
DataType,
IsUrl,
BeforeCreate,
AfterCreate,
AfterUpdate,
AfterDestroy,
AfterRestore,
DefaultScope,
AllowNull,
} from "sequelize-typescript";
import { Hour } from "@shared/utils/time";
import { WebhookSubscriptionValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import type { Event } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import Team from "./Team";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
@@ -47,6 +55,60 @@ class WebhookSubscription extends ParanoidModel<
> {
static eventNamespace = "webhookSubscriptions";
/**
* Returns the enabled webhook subscriptions for a team, caching the
* lightweight { id, events } projection in Redis to avoid a database query on
* every event. The cache is invalidated by model lifecycle hooks whenever a
* team's subscriptions change.
*
* @param teamId The team to load subscriptions for.
* @returns the enabled subscriptions' ids and subscribed event names.
*/
public static async findEnabledByTeamId(
teamId: string
): Promise<Array<{ id: string; events: string[] }>> {
return (
(await CacheHelper.getDataOrSet<Array<{ id: string; events: string[] }>>(
RedisPrefixHelper.getWebhookSubscriptionsKey(teamId),
async () => {
const subscriptions = await this.unscoped().findAll({
attributes: ["id", "events"],
where: { enabled: true, teamId },
raw: true,
});
return subscriptions.map((subscription) => ({
id: subscription.id,
events: subscription.events,
}));
},
Hour.seconds
)) ?? []
);
}
/**
* Determines whether a subscription configured for the given event names
* should receive an event with the given name. Pure so it can run against the
* cached projection as well as model instances.
*
* @param events The event names a subscription is configured for.
* @param eventName The name of the event being processed.
* @returns true if the event matches the subscription configuration.
*/
public static matchEvent(events: string[], eventName: string): boolean {
if (events.length === 1 && events[0] === "*") {
return true;
}
for (const e of events) {
if (e === eventName || eventName.startsWith(e + ".")) {
return true;
}
}
return false;
}
@NotEmpty
@Length({
max: WebhookSubscriptionValidation.maxNameLength,
@@ -105,6 +167,31 @@ class WebhookSubscription extends ParanoidModel<
}
}
@AfterCreate
@AfterUpdate
@AfterDestroy
@AfterRestore
static async invalidateCache(
model: WebhookSubscription,
options: { transaction?: Transaction | null }
) {
const invalidate = () =>
CacheHelper.removeData(
RedisPrefixHelper.getWebhookSubscriptionsKey(model.teamId)
);
// Defer invalidation until after the transaction commits so that a rollback
// does not leave the cache out of sync, and so a stale pre-commit read is
// not re-cached by a concurrent reader. Walk to the parent transaction when
// nested so the callback isn't lost when a savepoint releases.
if (options.transaction) {
const transaction = options.transaction.parent || options.transaction;
transaction.afterCommit(invalidate);
} else {
await invalidate();
}
}
// instance methods
/**
@@ -130,22 +217,11 @@ class WebhookSubscription extends ParanoidModel<
* Determines if an event should be processed for this webhook subscription
* based on the event configuration.
*
* @param event Event to ceck
* @param event Event to check
* @returns true if event is valid
*/
public validForEvent = (event: Event): boolean => {
if (this.events.length === 1 && this.events[0] === "*") {
return true;
}
for (const e of this.events) {
if (e === event.name || event.name.startsWith(e + ".")) {
return true;
}
}
return false;
};
public validForEvent = (event: Event): boolean =>
WebhookSubscription.matchEvent(this.events, event.name);
/**
* Calculates the signature for a webhook payload if the webhook subscription
@@ -17,6 +17,19 @@ export default class AuthenticationHelper {
return PluginManager.getHooks(Hook.AuthProvider);
}
/**
* Returns the human-readable display name for an authentication provider.
*
* @param id The authentication provider id, eg "google".
* @returns The display name if known, otherwise the provided id.
*/
public static getProviderName(id: string): string {
const provider = AuthenticationHelper.providers.find(
(hook) => hook.value.id === id
);
return provider?.name ?? id;
}
/**
* Returns the enabled authentication provider configurations for a team,
* if given otherwise all enabled providers are returned.
+13 -3
View File
@@ -246,7 +246,7 @@ export class DocumentHelper {
options?: HTMLOptions
) {
const node = DocumentHelper.toProsemirror(model);
let output = ProsemirrorHelper.toHTML(node, {
let output = await ProsemirrorHelper.toHTML(node, {
title:
options?.includeTitle !== false
? model instanceof Collection
@@ -817,8 +817,18 @@ export class DocumentHelper {
const textSame = oldChild.textContent === newChild.textContent;
if (textSame && oldChild.sameMarkup(newChild)) {
// Fully unchanged — keep original with its rich content
merged.push(oldChild);
// Compare against the round-tripped baseline: when the
// updated child is identical to a plain round-trip of the original,
// the patch did not touch it
if (!rtChild || newChild.eq(rtChild)) {
merged.push(oldChild);
} else if (!oldChild.isTextblock && !oldChild.isLeaf) {
// Container child changed deeper down — recurse to preserve rich
// content in the parts that did not change.
merged.push(DocumentHelper.mergeNodes(oldChild, newChild, rtChild));
} else {
merged.push(newChild);
}
} else if (textSame) {
// Attrs changed (e.g. checked state) but content same — merge attrs
// so that non-markdown-representable values (colwidth, highlight
+15 -10
View File
@@ -1,5 +1,5 @@
import emojiRegex from "emoji-regex";
import { JSDOM } from "jsdom";
import type { JSDOM } from "jsdom";
import { chunk, isMatch } from "es-toolkit/compat";
import { EditorState, type Plugin } from "prosemirror-state";
import {
@@ -20,6 +20,7 @@ import Diff from "@shared/editor/extensions/Diff";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import type { ExtendedChange } from "@shared/editor/lib/ChangesetHelper";
import textBetween from "@shared/editor/lib/textBetween";
import { withTrailingNode } from "@shared/editor/lib/trailingNode";
import EditorContainer from "@shared/editor/components/Styles";
import GlobalStyles from "@shared/styles/globals";
import light from "@shared/styles/theme";
@@ -106,15 +107,16 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
* @returns The content as a Y.Doc.
*/
static toYDoc(input: string | ProsemirrorData, fieldName = "default"): Y.Doc {
if (typeof input === "object") {
return prosemirrorToYDoc(
ProsemirrorHelper.toProsemirror(input),
fieldName
);
const node =
typeof input === "object"
? ProsemirrorHelper.toProsemirror(input)
: parser.parse(input);
if (!node) {
return new Y.Doc();
}
const node = parser.parse(input);
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
// Normalize to the editor's trailing-node form so the document opens without
// the editor inserting a trailing paragraph, which would be a spurious edit.
return prosemirrorToYDoc(withTrailingNode(node), fieldName);
}
/**
@@ -471,10 +473,13 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
* @param options Options for the HTML output
* @returns The content as a HTML string
*/
public static toHTML(node: Node, options?: HTMLOptions) {
public static async toHTML(node: Node, options?: HTMLOptions) {
let view;
let cleanupEnv;
// Loaded lazily to keep jsdom off the startup path — only HTML export needs it.
const { JSDOM } = await import("jsdom");
try {
const sheet = new ServerStyleSheet();
let html = "";
+1 -1
View File
@@ -109,7 +109,7 @@ async function presentDocument(
res.parentDocumentId = document.parentDocumentId;
res.createdBy = presentUser(document.createdBy);
res.updatedBy = presentUser(document.updatedBy);
res.collaboratorIds = document.collaboratorIds;
res.collaboratorIds = document.collaboratorIds ?? [];
res.templateId = document.templateId;
res.insightsEnabled = document.insightsEnabled;
res.popularityScore = document.popularityScore;
+1 -1
View File
@@ -17,7 +17,7 @@ export function presentDCRClient(
baseUrl: string,
oauthClient: OAuthClient,
{
includeRegistrationAccessToken = false,
includeRegistrationAccessToken,
includeCredentials = false,
}: {
includeRegistrationAccessToken: boolean;
+23
View File
@@ -1,8 +1,31 @@
import type { Event } from "@server/types";
export default abstract class BaseProcessor {
/**
* The event names this processor handles. The global event queue only creates
* a job for the processor when an event's name is listed here, or when it
* contains the `"*"` wildcard to match every event.
*/
static applicableEvents: (Event["name"] | "*")[] = [];
/**
* Optional hook run in the global event queue before a job is created for this
* processor. Implement it to cheaply opt out of events the processor will not
* act on and avoid the cost of an empty job. When omitted, every applicable
* event is queued.
*
* @param event The event about to be queued.
* @returns true if a job should be queued for this processor.
*/
static shouldQueue?: (event: Event) => Promise<boolean>;
/**
* Handle an applicable event. Called once per queued job, with retries on
* failure.
*
* @param event The event to process.
* @returns A promise that resolves once the event has been processed.
*/
public abstract perform(event: Event): Promise<void>;
/**
@@ -1,5 +1,3 @@
import { franc } from "franc";
import { iso6393To1 } from "iso-639-3";
import { Node } from "prosemirror-model";
import { schema, serializer } from "@server/editor";
import { Document } from "@server/models";
@@ -17,6 +15,13 @@ export default class DocumentUpdateTextTask extends BaseTask<DocumentEvent> {
const node = Node.fromJSON(schema, document.content);
document.text = serializer.serialize(node);
// Loaded lazily to keep the language-detection corpus off the startup path —
// only this worker task needs it.
const [{ franc }, { iso6393To1 }] = await Promise.all([
import("franc"),
import("iso-639-3"),
]);
const language = franc(DocumentHelper.toPlainText(document), {
minLength: 50,
});
+48 -32
View File
@@ -26,7 +26,7 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
zip,
pathInZip,
documentId,
format = FileOperationFormat.MarkdownZip,
format,
includeAttachments,
pathMap,
}: {
@@ -150,26 +150,12 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
includeAttachments = true
) {
const pathMap = this.createPathMap(collections, format);
Logger.debug(
"task",
`Start adding ${Object.values(pathMap).length} documents to archive`
);
for (const path of pathMap) {
const documentId = path[0].replace("/doc/", "");
const pathInZip = path[1];
await this.processDocument({
zip,
pathInZip,
documentId,
includeAttachments,
format,
pathMap,
});
}
Logger.debug("task", "Completed adding documents to archive");
await this.addDocumentsToArchive({
zip,
pathMap,
format,
includeAttachments,
});
return await ZipHelper.toTmpFile(zip);
}
@@ -200,28 +186,58 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
format
);
Logger.debug(
"task",
`Start adding ${Object.values(pathMap).length} documents to archive`
);
await this.addDocumentsToArchive({
zip,
pathMap,
format,
includeAttachments: true,
});
for (const entry of pathMap) {
const documentId = entry[0].replace("/doc/", "");
const pathInZip = entry[1];
return await ZipHelper.toTmpFile(zip);
}
/**
* Processes each unique document in the path map and adds it to the zip.
*
* @param zip The yazl ZipFile to add files to
* @param pathMap Map of document urls to their path in the zip
* @param format The format to export in
* @param includeAttachments Whether to include attachments in the export
*/
private async addDocumentsToArchive({
zip,
pathMap,
format,
includeAttachments,
}: {
zip: ZipFile;
pathMap: Map<string, string>;
format: FileOperationFormat;
includeAttachments: boolean;
}) {
const processedPaths = new Set<string>();
Logger.debug("task", `Start adding documents to archive`);
for (const [url, pathInZip] of pathMap) {
// A document may be keyed by multiple urls in the path map, only
// process each file in the zip once.
if (processedPaths.has(pathInZip)) {
continue;
}
processedPaths.add(pathInZip);
await this.processDocument({
zip,
pathInZip,
documentId,
includeAttachments: true,
documentId: url.replace("/doc/", ""),
includeAttachments,
format,
pathMap,
});
}
Logger.debug("task", "Completed adding documents to archive");
return await ZipHelper.toTmpFile(zip);
}
/**

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