Compare commits

...

478 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
Tom Moor f6fbbcb1ad fix: Enter on image should ad (#12530)
d new paragraph below
2026-05-29 23:20:47 -04:00
Tom Moor 70b6476afa Remove resize grid-snap (#12528)
* fix: Remove unused grid snapping from element resizing

Horizontal resizing snapped widths to a 5% grid, which is no longer
desired. Replace the only remaining use of the gridSnap prop (the
minimum-width clamp) with a named constant and drop the prop entirely.

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

* fix: Remove resize lag by disabling size transition while dragging

The width/height CSS transition on resizable elements existed to smooth
the discrete jumps from grid snapping. With pixel-by-pixel resizing the
element perpetually animates toward a target ~150ms in the future, so it
visibly trails the cursor. Disable the transition while actively dragging
and restore it afterwards so snap-back and collaborative size changes
still animate.

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

* fix: Constrain image resizing to editor edge instead of snapping to natural size

When dragging an element past the editor bounds, the full-width sentinel
forced the width to the natural size. For images narrower than the editor
this snapped them back to their (smaller) natural width at the boundary.
Only use the natural-width sentinel when the image is genuinely wider than
the editor; otherwise constrain to the editor edge.

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

* PR feedback

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:46:59 -04:00
Tom Moor a37bb13956 perf: Avoid redundant import lookup when presenting documents (#12529)
The FileOperation import association was fetched for every non-public
document but only used when sourceMetadata is present. Move the lookup
inside that branch to eliminate an N+1 query for documents that are not
imports, benefiting every endpoint that presents documents.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:46:43 -04:00
Tom Moor c91272f820 fix: Always use HTML output from Claude
closes #12520
2026-05-29 21:53:31 -04:00
Tom Moor 60bf47ede0 fix: Prevent foreign key violation when permanently deleting a team (#12527)
The attachment cleanup loop used findAllInBatches, which advances an
OFFSET each iteration. Because the callback deletes each batch, the
remaining rows shift backwards and the advancing offset skips over them,
leaving attachments that still reference the team. team.destroy() then
failed with attachments_teamId_fkey.

Page from offset 0 until no attachments remain, and remove the now
redundant per-user attachment delete so the loop is the single
authoritative cleanup.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:47:18 -04:00
Tom Moor 03fe74710c fix: Undo/redo events duplicated (#12525)
* fix: Undo/redo events duplicated

* fix: Guard history use
Prevent cross polination of editors

* Remove unused check
2026-05-29 20:04:11 -04:00
Tom Moor 370934bb0e fix: Prevent crash inserting files when schema has no attachment node (#12526)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:28:10 -04:00
Tom Moor e044014cea fix: Disable webhooks when deleting associated user (#12524)
* fix: Disable webhooks created by deleted users

* Delete -> disable
2026-05-29 17:44:29 -04:00
dependabot[bot] 5aff60e28b chore(deps): bump axios from 1.15.2 to 1.16.1 (#12523)
Bumps [axios](https://github.com/axios/axios) from 1.15.2 to 1.16.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 12:15:24 -04:00
Tom Moor fecca544f9 chore: Normalize permission logic between API/MCP doc creation (#12517) 2026-05-28 22:42:40 -04:00
Tom Moor 1eba87020c fix: Prevent block menu trigger when marked (#12515)
* Prevent block menu trigger when marked

* PR feedback
2026-05-28 21:30:53 -04:00
Tom Moor 3f92e96006 fix: Outdent code with shift-tab behavior (#12514)
* fix: Outdent code with shift-tab behavior

* PR feedback
2026-05-28 21:08:46 -04:00
Tom Moor ae5cd6a159 fix: Allow service worker to load on custom domains (#12502)
* fix: Allow service worker to load on custom domains

Add explicit worker-src 'self' so the service worker can register on
team custom domains. Without it, browsers fall back to script-src which
only lists env.URL and env.CDN_URL, blocking /static/sw.js on hosts
like docs.getoutline.com.

* fix: Switch worker-src approach to script-src 'self' for type safety

The @types/koa-helmet definitions don't include workerSrc. Add 'self'
to script-src instead — worker-src falls back to script-src per spec,
and 'self' matches the document origin on custom domains.

* fix: Properly add worker-src directive without script-src widening

Extract the CSP directives to a local variable so workerSrc can be
included despite koa-helmet's outdated type definitions missing it
(the underlying helmet supports it). Also drop @types/koa-helmet
since the package now ships its own (equivalent) types.
2026-05-28 09:07:05 -04:00
Tom Moor d2a0bf9923 fix: Avoid team invariant violation on OAuth authorize error (#12506)
When /oauthClients.info returns an AuthorizationError, ApiClient logs
the user out and clears auth.team. The subsequent re-render of the
Authorize component hit the strict useCurrentTeam() and threw before
the error UI could render. Make the inner hook tolerate a missing team
and fold it into the existing error branch.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:06:58 -04:00
Tom Moor deadaa00f1 fix: Disable floating toolbar interaction during open animation (#12508)
Closes #12503

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:06:55 -04:00
Tom Moor 6366859935 chore: Remove Koa middleware span reporting (#12501) 2026-05-27 23:18:37 -04:00
Tom Moor 82743b1c0a feat: Allow http webhook urls when self-hosting (#12499) 2026-05-27 22:52:15 -04:00
Tom Moor 76a3ba4e83 fix: Normalize IP addresses to avoid validation errors (#12500)
* fix: Normalize IP addresses to avoid validation errors on audit columns

Koa's `ctx.request.ip` can yield values that fail Sequelize's `isIP`
validation (X-Forwarded-For chains, IPv6 zone identifiers, "unknown"
from misconfigured proxies). This drops the IP metadata silently
instead of raising a 500 on Event/User writes.

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

* test: Cover IP normalization on User setters

Reviewer feedback. Also switches the column-options `set` to TypeScript
get/set accessors — the original approach was shadowed by the class
field declaration and never actually fired, which the new tests would
have caught.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:52:05 -04:00
Tom Moor 09e99ac98d fix: Graceful exit when import is canceled beneath import task (#12497) 2026-05-27 22:37:54 -04:00
Tom Moor c158697c91 fix: Reject image/video dimension promises with real Error (#12498)
The onerror handlers in FileHelper passed the raw DOM Event to reject,
which Sentry surfaced as "Event captured as promise rejection" with no
stack. Reject with an Error and revoke the blob URL on failure.
2026-05-27 22:34:55 -04:00
Tom Moor 7473d5b437 fix: Allow reordering subdocuments with document-only access (#12493)
* fix: Allow reordering subdocuments with document-only access

When a user has "Manage" (or any move-eligible) permission on a parent
document but no access to its collection, the sidebar drop cursors were
hidden because they gated on collection.isManualSort, and the move
handler bailed out because it built the payload from collection.id.
Fall back to the document's own collectionId and the move policy so the
reorder UX works for sourced document memberships.

* fix: Structure not refetched
parentDocumentId not provided
2026-05-27 21:33:33 -04:00
Tom Moor ded7ff994e fix: Indent/outdent (#12496) 2026-05-27 20:55:41 -04:00
Tom Moor a4a67f2cdd fix: Upgrade yauzl, improve stream close handling 2026-05-27 20:33:33 -04:00
Tom Moor c3ba14f069 chore: Refactor SelectionToolbar to menu registry (#12439)
* refactor: introduce declarative menu registry for selection toolbar

Replace the hard-coded if-else chain in SelectionToolbar with a
priority-based menu registry system. Extensions can now declare
selection toolbar menus via `selectionToolbarMenus()`, following the
same pattern as `commands()` and `keys()`.

Key changes:
- Add SelectionContext interface computed once per toolbar render
- Add SelectionToolbarMenuDescriptor for declarative menu registration
- Add selectionToolbarMenus() to Extension base class
- Add buildSelectionContext() utility to eliminate repeated state queries
- ExtensionManager collects and sorts menus from all extensions
- SelectionToolbarExtension registers all 10 existing menus
- All menu functions now accept SelectionContext instead of raw state
- SelectionToolbar uses registry lookup instead of if-else chain

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

* refactor: import t directly from i18next in menu functions

Remove the `t: TFunction` parameter from all menu functions and the
`SelectionToolbarMenuDescriptor.getItems` signature. Each menu file
now imports `t` directly from i18next, matching the pattern used
throughout the rest of the codebase (e.g. Image.tsx, Link.tsx).

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

* refactor: move divider menu into HorizontalRule node extension

The divider selection toolbar menu is now declared via
selectionToolbarMenus() on the HorizontalRule node class, co-locating
the menu with the node that owns it. Delete the standalone
app/editor/menus/divider.tsx file and remove the entry from
SelectionToolbarExtension.

This is the first menu migrated from the centralized toolbar extension
to an individual node extension, demonstrating the pattern for the
remaining menus.

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

* refactor: check readOnly in matches predicate for divider menu

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-27 20:28:17 -04:00
Tom Moor e9e13c4819 Another rev on transaction statement timeout (#12483)
* Another rev on transaction statement timeout

* docs

* PR feedback
2026-05-27 20:28:03 -04:00
Tom Moor 48aa4f33ce chore: Upgrade ipaddr.js (#12491) 2026-05-27 20:27:28 -04:00
Tom Moor f7b2eb0173 Use segmented OTP input for delete confirmation dialogs (#12495) 2026-05-27 19:44:16 -04:00
Tom Moor 45c797653f feat: Format word at cursor position (#12492)
* wip

* refactor

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 18:44:07 -04:00
Tom Moor b424d92724 chore: Bump tmp dep (#12494) 2026-05-27 18:39:49 -04:00
Tom Moor 798184435b fix: Show upload progress on import dialog button (#12488)
* fix: Show upload progress on import dialog button
2026-05-27 18:28:21 -04:00
Tom Moor 0f2513346a Hardening of scope validation (#12490) 2026-05-27 18:27:34 -04:00
Tom Moor 1186ddd3c0 fix: Enable import into document with write permissions only (#12485) 2026-05-27 08:32:09 -04:00
Tom Moor c4fe093a0d fix: Skip Sentry capture for expected websocket "No access token" error (#12487)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:31:50 -04:00
Tom Moor ecaf116990 fix: Guard against out-of-range position in scrollToAnchor (#12489)
The MutationObserver callback could throw an uncaught RangeError when
posAtDOM returned a position outside the document, since the existing
try/catch only wrapped the observer setup, not the async callback.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:31:43 -04:00
Tom Moor e6f9b48530 fix: Make search highlight chip clickable in desktop app (#12482) 2026-05-26 21:23:19 -04:00
Tom Moor 70c55e4a42 feat: Add support for code blocks in comments (#12480)
* feat: Add support for code blocks in comments

* Add code_block
2026-05-26 20:38:46 -04:00
Tom Moor 667bfe68c5 fix: Retry Notion API 5xx errors with exponential backoff (#12481)
The Notion API can return transient 5xx errors during imports. Retry these
up to 8 times with exponential backoff, tracked separately from the existing
timeout/rate-limit retry budget.
2026-05-26 20:38:12 -04:00
Tom Moor 84c00cfae7 fix: Distinguish rate limiter error (#12479) 2026-05-26 20:29:56 -04:00
Tom Moor 2c3e736eb3 fix: Avoid logging error when team not found in apex auth redirect (#12478)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:29:18 -04:00
Tom Moor 64ccdca0d7 fix: Guard table content changing mid-drag (#12476)
* fix: Guard table content changing mid-drag

* docs
2026-05-26 20:18:23 -04:00
Tom Moor 62788c45e0 fix: Remove fragment from AuthenticationHelper (#12477) 2026-05-26 20:18:14 -04:00
Tom Moor b9addda229 perf: Reduce deletion batch size (#12474) 2026-05-26 20:12:26 -04:00
Tom Moor a23b04c8fa fix: Prevent ISE when tsquery tail interleaves operator and escape chars (#12475)
When a user query produces a pg-tsquery output ending in mixed `&` and `\`
characters (e.g. `"plugins"&\`), stripping them with separate single-char
regexes in a fixed order could leave a dangling `&` operator, causing
Postgres to reject the query with "no operand in tsquery".

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:10:49 -04:00
Tom Moor 15213bbeb0 fix: Skip export attachments with malformed key (#12470)
Attachments whose key ends in a trailing slash have no filename
component and cause yazl to throw, aborting the entire export. Skip
them with a warning and continue the export instead.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:57:36 -04:00
Tom Moor 03950af3b7 fix: TypeError when document.collaboratorIds is null (#12471)
Guard against null collaboratorIds when persisting collaborative
updates; the DB column has no default and can be NULL on older rows.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:56:56 -04:00
Tom Moor 8989287e8a perf: Add missing indexes on foreign keys referencing documents (#12473)
* perf: Add missing indexes on foreign keys referencing documents

Cascade deletes on the documents table were forced into sequential scans
on file_operations, share_subscriptions, and access_requests because
their documentId columns lacked a usable single-column index.

Also fixes lint-staged to skip oxlint when every staged file matches an
.oxlintrc.json ignore pattern, since oxlint exits 1 in that case.

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

* Handle oxlint no-files exit instead of mirroring ignorePatterns

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:56:46 -04:00
Tom Moor 6bab00b92e chore(deps): upgrade octokit to v5 and @octokit/auth-app to v8 (#12472)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:47:58 -04:00
Tom Moor 1e3aa2c59a fix: Scroll into view broken in virtualized sidebar (#12462) 2026-05-26 07:07:05 -04:00
Tom Moor f3da1bc79e fix: Remapping internal links on import (#12461)
* fix: No remapping of internal links

closes #9584

* PR feedback, testing

* tsc
2026-05-26 07:06:56 -04:00
Tom Moor 8c1be70198 Disable collapsing settings sidebar (#12460) 2026-05-25 21:06:14 -04:00
dependabot[bot] a9a54d5ada chore(deps): bump the aws group with 5 updates (#12455)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1045.0` | `3.1053.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.1045.0` | `3.1053.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.1045.0` | `3.1053.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.1045.0` | `3.1053.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.1045.0` | `3.1053.0` |


Updates `@aws-sdk/client-s3` from 3.1045.0 to 3.1053.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1053.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.1045.0 to 3.1053.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1053.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.1045.0 to 3.1053.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1053.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.1045.0 to 3.1053.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1053.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.1045.0 to 3.1053.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1053.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 18:57:01 -04:00
dependabot[bot] 5126d8540e chore(deps): bump uuid from 11.1.0 to 11.1.1 (#12456)
Bumps [uuid](https://github.com/uuidjs/uuid) from 11.1.0 to 11.1.1.
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/v11.1.1/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v11.1.0...v11.1.1)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 11.1.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-05-25 18:56:47 -04:00
dependabot[bot] 0c3ddef228 chore(deps-dev): bump terser from 5.44.1 to 5.48.0 (#12457)
Bumps [terser](https://github.com/terser/terser) from 5.44.1 to 5.48.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.44.1...v5.48.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 18:56:24 -04:00
dependabot[bot] 3f207aea49 chore(deps-dev): bump oxlint from 1.50.0 to 1.66.0 (#12458)
Bumps [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) from 1.50.0 to 1.66.0.
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.66.0/npm/oxlint)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 18:56:01 -04:00
Translate-O-Tron dcc3805438 New Crowdin updates (#12400)
* fix: New Danish translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Spanish translations from Crowdin [ci skip]
2026-05-25 17:03:18 -04:00
Tom Moor ecafd5f32a chore: Update JSON importer to use zip streaming (#12380)
* chore: Update JSON importer to use zip streaming, new importer flow

* chore: Drop teamId from import urlId collision check and remove unused internal-id scaffolding

urlId is globally unique on Document/Collection so the team scope was wrong.
Also removes leftover internal-id generation in JSONAPIImportTask that was
never used in task input/output.

* Restore classes used upstream
2026-05-25 17:03:02 -04:00
Tom Moor f9dc1a3983 fix: documents.list with Draft status filter throws database error (#12426)
* fix: documents.list with Draft status filter throws database error

The count() query referenced $memberships.id$ in WHERE but had no
membership include, causing "missing FROM-clause entry for table
memberships". The findAll path was also silently dropping drafts because
withMembershipScope defaulted to defaultScope (which filters publishedAt
!= null). Pre-fetch the user's UserMembership document IDs and filter by
id IN (...) on both find and count, and pass includeDrafts: true when
the Draft filter is active.

* Preserve template/trial filters when including drafts

* Move template/trial filters into withDrafts scope

* Revert withDrafts scope filters, apply at call site instead

Adding template/trial filters to withDrafts broke includes in places
like Share's withCollectionPermissions where the document include must
remain optional (LEFT JOIN) — adding a where promoted it to INNER JOIN
and dropped shares without a documentId.
2026-05-25 17:02:46 -04:00
Wars 38eda7fa61 fix: correct Safari heading widget handling for Chinese IME (#12453)
Safari keeps the heading actions widget at the end of headings to avoid
selection issues, but the widget was still using side metadata suited to
the leading placement.

With Chinese IME composition at the end of a heading, same-position
insertion could interact with the contentEditable=false widget and leave
the editor selection stuck. After that, Backspace stopped working until
the page was refreshed.

Use positive side metadata for the Safari trailing widget so composed
text stays before it, allow relaxed selection around the widget, and add
a narrow Safari-only ArrowLeft fallback for the heading-end boundary.

Chinese IME has been manually verified. Other composition-based IMEs may
follow a similar path, but are not claimed as verified here.
2026-05-25 16:51:35 -04:00
Tom Moor 6461aabc52 feat: Add Catalan language option (#12454)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:56:32 -04:00
github-actions[bot] 4bd5585f8c fix: Update Node.js to 24.16.0 (#12448)
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-05-25 08:34:10 -04:00
Tom Moor 1a4033dd2d fix: Sporadic infinite loop rendering document with imported code blocks that have unknown languages (#12444) 2026-05-24 12:03:02 -04:00
Tom Moor 9e725d618d perf: Sidebar virtualization and re-render optimization (#12443)
* perf: Prevent action context invalidation on location change

* PR feedback

* virtualization

* fix: Initial visiblity incorrect

* PR feedback
2026-05-24 08:57:43 -04:00
Tom Moor 08c0390295 chore: Remove unnecessary package resolutions (#12442)
Remove `debug: 4.3.4` resolution which was forcing a downgrade – packages
requesting ^4.4.x now resolve to their correct versions. Remove
`ajv@npm:~8.13.0` resolution as no package in the dependency tree requests
that range anymore.

https://claude.ai/code/session_01JmpWGCUCVdKqN3MgsYc3fi

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-24 07:27:50 -04:00
Tom Moor a1b9f900c7 perf: Avoid correlated subquery in Slack hooks user lookup (#12432)
* perf: Avoid correlated subquery in Slack hooks user lookup

Query UserAuthentication directly by indexed providerId and load the
associated User and Team, instead of driving from User.findOne with a
required hasMany include — which Sequelize translates into a correlated
subquery that scans the users table.

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

* fix: Scope Slack fallback user lookup to matching AuthenticationProvider

The fallback in findUserForRequest matched any UserAuthentication with
the same providerId, which is only unique per (providerId, userId).
A colliding external user id from another workspace or provider could
resolve a user from the wrong team. Constrain via the AuthenticationProvider
join (name = "slack", providerId = serviceTeamId).

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

* test

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 14:15:22 -04:00
dependabot[bot] 92be631350 chore(deps): bump qs from 6.15.1 to 6.15.2 (#12437) 2026-05-23 11:27:57 -04:00
Tom Moor 8d44a0fd92 chore: Migrate from JSZip to Yazl (#12408)
* chore: Migrate from JSZip to Yazl

* Add koa stream helper, PR feedback
2026-05-21 23:27:23 -04:00
Tom Moor d43280f08e fix: Calling method that does not exist on editor (#12427) 2026-05-21 23:26:04 -04:00
Tom Moor bf62bd04b0 fix: pg_bouncer statement timeout error (#12428) 2026-05-21 23:25:51 -04:00
Copilot dd2e8f258d Handle double-click submission in DocumentExplorer actions (#12417)
* Initial plan

* Support double-click submit in document explorer

* Remove test

* Fix double-click submit in document explorer

Single click now sets the selection instead of toggling it, so the two
clicks preceding a dblclick no longer flicker the selection on/off.
Submit handlers accept the node directly to avoid the stale-state race
across the click sequence, and button onClick handlers are wrapped so
the synthetic MouseEvent isn't passed in as the path argument.

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

* PR feedback

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:10:25 -04:00
Tom Moor 5309e8bb01 fix: Don't report upstream OAuth provider errors to Sentry (#12425)
* fix: Don't report upstream OAuth provider errors to Sentry

TokenError and AuthorizationError from passport-oauth2 represent
input problems from the upstream provider (expired or already-redeemed
codes, access_denied, etc) rather than server bugs. Log them at warn
level and redirect with the standard auth-error notice instead of
sending to the error reporter.

* Warn-only for OAuth provider errors, keep redirect flow shared

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:09:20 -04:00
Tom Moor def9da6a12 fix: Empty Notion table crashes importer (#12421)
* fix: Empty Notion table crashes importer

* Add tests
2026-05-21 21:27:02 -04:00
Tom Moor 63a6ed7f8d fix: Apply statement_timeout on request-handling processes (#12422)
* fix: Apply Postgres statement_timeout on request-handling processes

Sets `statement_timeout` to REQUEST_TIMEOUT on the Sequelize connection
pool when the process handles HTTP requests (web/api/collaboration/
websockets/admin) and does not also run worker/cron. Prevents a single
runaway query from saturating the shared Postgres instance and cascading
into timeouts across all endpoints.

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

* Drop dead `api` service check

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

* Only apply statement_timeout in forked cluster workers

Skips the timeout in the master process so startup migrations driven
from `checkPendingMigrations` are not cancelled mid-execution.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:26:30 -04:00
Tom Moor cf488508b7 fix: Ignore "Premature close" stream errors in Sentry (#12424)
Client disconnects mid-response surface as "Premature close" errors from
Node's stream end-of-stream helper. These are expected and add noise to
Sentry, similar to the EPIPE/ECONNRESET errors already filtered.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:26:27 -04:00
Copilot efc988fb9f Prevent unintentional trashing of non-empty untitled drafts on editor unmount (#12418)
* Initial plan

* Fix draft auto-delete check for non-empty untitled docs

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-21 20:15:54 -04:00
dependabot[bot] b639841555 chore(deps): bump @tootallnate/once from 2.0.0 to 2.0.1 (#12415)
Bumps [@tootallnate/once](https://github.com/TooTallNate/once) from 2.0.0 to 2.0.1.
- [Release notes](https://github.com/TooTallNate/once/releases)
- [Changelog](https://github.com/TooTallNate/once/blob/v2.0.1/CHANGELOG.md)
- [Commits](https://github.com/TooTallNate/once/compare/2.0.0...v2.0.1)

---
updated-dependencies:
- dependency-name: "@tootallnate/once"
  dependency-version: 2.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 18:34:40 -04:00
Tom Moor f06defaa14 feat: Add breadcrumb to docs in command menu (#12403) 2026-05-21 17:42:04 -04:00
Tom Moor fcf26e4b9b fix: Mention menu does not appear when pasting link with trailing newline (#12402) 2026-05-21 17:41:54 -04:00
Tom Moor df117ebad5 fix: Updating collection description via MCP does not take (#12410) 2026-05-21 17:41:37 -04:00
dependabot[bot] 3a6df26c8c chore(deps): bump js-cookie from 3.0.5 to 3.0.7 (#12414) 2026-05-21 17:41:07 -04:00
Tom Moor 80d90e3201 fix: Slack notifications show "Untitled" for documents without titles (#12406)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 17:49:44 -04:00
Copilot 841ab022a6 Sanitize Windows-invalid characters in exported filenames (#12407)
* Sanitize Windows-invalid ZIP filename characters

Agent-Logs-Url: https://github.com/outline/outline/sessions/539082bc-597f-463d-b77c-6eb1bcf9bffa

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

* Refine Windows filename sanitization regex handling

Agent-Logs-Url: https://github.com/outline/outline/sessions/539082bc-597f-463d-b77c-6eb1bcf9bffa

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

* PR feedback

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-05-20 17:49:30 -04:00
dependabot[bot] c875a92b86 chore(deps): bump vite-plugin-pwa from 1.2.0 to 1.3.0 (#12321)
Bumps [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) from 1.2.0 to 1.3.0.
- [Release notes](https://github.com/vite-pwa/vite-plugin-pwa/releases)
- [Commits](https://github.com/vite-pwa/vite-plugin-pwa/compare/v1.2.0...v1.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 20:52:52 -04:00
dependabot[bot] a11a8442dc chore(deps): bump @fast-csv/parse from 5.0.5 to 5.0.7 (#12389)
Bumps [@fast-csv/parse](https://github.com/C2FO/fast-csv/tree/HEAD/packages/parse) from 5.0.5 to 5.0.7.
- [Release notes](https://github.com/C2FO/fast-csv/releases)
- [Changelog](https://github.com/C2FO/fast-csv/blob/main/packages/parse/CHANGELOG.md)
- [Commits](https://github.com/C2FO/fast-csv/commits/v5.0.7/packages/parse)

---
updated-dependencies:
- dependency-name: "@fast-csv/parse"
  dependency-version: 5.0.7
  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-05-19 20:52:29 -04:00
Tom Moor 1e2159edf7 chore: Update ws dep resolution (#12398) 2026-05-19 20:52:14 -04:00
ZhuoYang Wu(阿离) 07118e8c94 fix: restore function (#12395) 2026-05-19 20:33:11 -04:00
dependabot[bot] 620c654b26 chore(deps): bump react-colorful from 5.6.1 to 5.7.0 (#12386)
* chore(deps): bump react-colorful from 5.6.1 to 5.7.0

Bumps [react-colorful](https://github.com/omgovich/react-colorful) from 5.6.1 to 5.7.0.
- [Release notes](https://github.com/omgovich/react-colorful/releases)
- [Changelog](https://github.com/omgovich/react-colorful/blob/master/CHANGELOG.md)
- [Commits](https://github.com/omgovich/react-colorful/commits/5.7.0)

---
updated-dependencies:
- dependency-name: react-colorful
  dependency-version: 5.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

* Use new prop

---------

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>
2026-05-18 22:57:52 -04:00
dependabot[bot] 6e2b53315b chore(deps-dev): bump @vitest/ui from 4.1.5 to 4.1.6 (#12387)
Bumps [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) from 4.1.5 to 4.1.6.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/ui)

---
updated-dependencies:
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.6
  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-05-18 22:54:09 -04:00
dependabot[bot] 57b9cfdcf2 chore(deps): bump @dotenvx/dotenvx from 1.64.0 to 1.66.0 (#12388)
Bumps [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) from 1.64.0 to 1.66.0.
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.64.0...v1.66.0)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.66.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:28:55 -04:00
Translate-O-Tron 16470f0b8d New Crowdin updates (#12166)
* fix: New French translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Catalan translations from Crowdin [ci skip]
2026-05-18 22:10:30 -04:00
dependabot[bot] 8f8cbb6b71 chore(deps): bump umzug from 3.8.2 to 3.8.3 (#12385)
Bumps [umzug](https://github.com/sequelize/umzug) from 3.8.2 to 3.8.3.
- [Release notes](https://github.com/sequelize/umzug/releases)
- [Changelog](https://github.com/sequelize/umzug/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sequelize/umzug/compare/v3.8.2...v3.8.3)

---
updated-dependencies:
- dependency-name: umzug
  dependency-version: 3.8.3
  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-05-18 22:09:53 -04:00
dependabot[bot] c8c2bd8cc9 chore(deps-dev): bump @types/koa from 2.15.0 to 2.15.1 (#12384)
Bumps [@types/koa](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/koa) from 2.15.0 to 2.15.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/koa)

---
updated-dependencies:
- dependency-name: "@types/koa"
  dependency-version: 2.15.1
  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-05-18 22:09:19 -04:00
dependabot[bot] aab7de9781 chore(deps): bump i18next-http-backend from 3.0.5 to 3.0.6 (#12383)
Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 3.0.5 to 3.0.6.
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/compare/v3.0.5...v3.0.6)

---
updated-dependencies:
- dependency-name: i18next-http-backend
  dependency-version: 3.0.6
  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-05-18 22:09:06 -04:00
dependabot[bot] 158cac0a8a chore(deps): bump react-hook-form from 7.74.0 to 7.76.0 (#12382)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.74.0 to 7.76.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.74.0...v7.76.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.76.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:08:39 -04:00
Tom Moor d2b719a5a9 fix: Not possible to collapse code block at beginning of doc (#12381) 2026-05-18 22:00:45 -04:00
Tom Moor 597b6d801c fix: Allow deleting failed and canceled imports (#12379)
* fix: Allow deleting failed and canceled imports

The delete policy only permitted imports in the Completed state, so the
overflow menu for Errored or Canceled imports rendered with no items.

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

* test: Cover Errored and Canceled in imports.delete

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:37:16 -04:00
Tom Moor 4a6e94be3f fix: avoid "Imported from undefined" in document insights (#12378)
Only render the "Imported from" line when a source name or filename is
actually available, since sourceMetadata can exist without either.
2026-05-18 20:08:12 -04:00
Tom Moor a60d02898e fix: Hide position submenu for alphabetically-sorted collections (#12377)
When a collection is sorted alphabetically the document position is
auto-determined, so the "New document" submenu (Before/After/Nested) is
replaced by a direct "New document" action that creates a nested
document.
2026-05-18 20:07:53 -04:00
Tom Moor ee5164290d perf: Move Markdown importer to zip stream (#12372)
* perf: Move Markdown importer to zip stream

* refactor

* refactor: Extract zip walk + tree builder into ZipHelper

Adds `ZipHelper.walk` and `ZipHelper.toFileTree` so other importers can
stream zip contents without extracting to disk. Tree construction uses
an O(1) path → node map; `./`-prefixed entries are normalized, while
dotfiles, `__MACOSX`, and `..` segments are filtered.

* PR feedback
2026-05-18 18:32:58 -04:00
dependabot[bot] 5d32db86cf chore(deps): bump brace-expansion from 5.0.5 to 5.0.6 (#12376)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.5 to 5.0.6.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.5...v5.0.6)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 14:18:16 -04:00
Tom Moor ab3994f3f1 feat: Comments sidebar in image lightbox (#12335)
* feat: Toggle comments sidebar in editor lightbox

Adds a new comments toggle button to the lightbox top-right actions. When
toggled the sidebar slides out on the right and shows only the threads
anchored to the active image node. A new comment form at the bottom
creates a thread anchored to the image via a comment mark on the node.

https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7

* fix: Make lightbox comments sidebar interactable

The sidebar was being rendered as a sibling of Dialog.Content, so Radix's
focus/click-outside trap blocked all interaction with it. Move it inside
Dialog.Content so clicks and focus stay within the dialog.

Also scope the lightbox handleKeyDown to only preventDefault and act on
arrow/escape keys — and bail out entirely when typing into an input,
textarea, or contenteditable so the comment form receives keystrokes.

https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7

* fix: Align lightbox comments header with action buttons

Nudge the sidebar Comments heading 4px down so its baseline lines up
with the lightbox top-right action bar.

https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7

* fix: Render lightbox sidebar popovers inside the dialog

Reactions, menus, and tooltips inside the lightbox comments sidebar were
portalling into the editor wrapper via PortalContext — which is hidden
behind the lightbox overlay. Provide a PortalContext that targets the
sidebar element itself so popovers render inside the dialog and remain
visible.

https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7

* fix: Prevent lightbox handlers from stealing focus from reply input

Pointer events bubbling out of the comments sidebar were reaching the
ancestor Dialog.Content / lightbox handlers and somehow disrupting focus
on the ProseMirror reply input. Stop propagation of pointer, mouse, and
click events at the CommentsSidebar so the sidebar owns its own
interaction handling.

https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7

* fix: Anchor lightbox close animation to current image position

The close animation's translation was calculated relative to the image
position cached when the image first loaded — before the comments
sidebar could shift the image left. Recapture the natural position at
the start of setupZoomOut so the animation correctly starts where the
image actually is when the sidebar is open.

https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7

* fix: Fade the comments sidebar with the rest of the lightbox

The sidebar previously had only a slide-in animation on mount and stayed
fully opaque while the rest of the lightbox faded out on close. Wire the
sidebar to the shared fadeOut animation so it disappears in lockstep
with the overlay and action controls.

https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7

* Final fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-17 18:57:19 -04:00
mehmet turac 1d919cc56a fix: initialize text selection state (#12366)
* fix: initialize text selection state

* Delete app/hooks/useTextSelection.test.ts

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2026-05-17 18:11:31 -04:00
Tom Moor 77cee2806c chore: getJWTToken -> getSessionToken (#12371)
* getJWTToken -> getSessionToken

Ensure expiry is included in payload

* Refactor test harness to avoid direct usage of getSessionToken
2026-05-17 16:58:52 -04:00
Tom Moor 4774fa4fd0 Weekly insights rollup (#12113)
* Weekly insights rollup

* fix: Avoid eager db instance creation in DocumentInsight model

Importing sequelize at the top level triggered createDatabaseInstance
during module load, which caused unrelated test suites that transitively
require the model to fail. Use the instance-bound this.sequelize in the
static method instead.

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

* fix: Skip soft-deleted documents in weekly insights rollup

The weekly task was deleting daily rows for soft-deleted documents
without creating a weekly replacement, since rollupPeriod filters them
out. Join to documents in both the week-discovery query and the DELETE
to keep behavior consistent — historical daily rows for deleted docs are
left for the cleanup task to remove at the retention boundary.

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

* refactor: Bind cutoff days param and add date predicate in weekly rollup

Moves CUTOFF_DAYS from string interpolation to a bound parameter and
adds a plain `date <` predicate so the planner can use the
(documentId, date, period) index before evaluating date_trunc.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 09:48:30 -04:00
Tom Moor f4e7c43fe4 chore(deps): Remove obsolete lodash-es resolution (#12369)
The `lodash-es@npm:4.17.23` resolution targeted a specific descriptor
that is no longer requested by any package — only `lodash-es: ^4.17.21`
appears in the dependency tree now, which resolves naturally to 4.18.1.
Removing the resolution produces no change in yarn.lock.

All other resolutions still prevent regressions (yarn install would
install a strictly lower version without them) or serve as project-pin
dedupe targets retained for compatibility, matching the rationale from
the prior audit in #12304.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-17 07:35:41 -04:00
Tom Moor c1010fc410 fix: Back to using relative path 2026-05-16 21:08:03 -04:00
Tom Moor 879d2b8198 fix: Allow connecting additional auth providers on custom domain (#12364)
* fix: Unable to link secondary auth provider on custom domain

* doc

* chore: Custom -> Apex transfer token

* Refactor, address security concerns

* Ensure OAuth intent is single-use

* Secure OAuth state actor binding

* Use scrypt for OAuth actor session binding
2026-05-16 19:56:21 -04:00
Tom Moor 82d7041b6b chore: Refactor Markdown importer to use new import pipeline (#12361)
* chore: Refactor Markdown importer to use new import pipeline

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 14:10:15 -04:00
Tom Moor 170f59c6ba fix: Multi-tab logout OIDC redirect (#12333) 2026-05-16 08:43:15 -04:00
Tom Moor a5c22bbb09 feat(mcp): Add commentCount to document info response (#12355)
* Add commentsCount to MCP document info response

* fix: refine MCP document comment counts
2026-05-16 08:42:48 -04:00
Tom Moor 2fb34a400d fix: Improve resilience of markdown importer (#12357) 2026-05-14 23:37:55 -04:00
Tom Moor 061ba46255 fix: Unable to link secondary auth provider on custom domain (#12356)
* fix: Unable to link secondary auth provider on custom domain

* doc
2026-05-14 21:02:12 -04:00
Tom Moor 246aa83071 fix: Potential fix for mis-sized mermaid diagrams (#12354)
closes #11874
2026-05-14 19:34:40 -04:00
Tom Moor 9db539dfce fix: Disabling of authorization providers with env (#12349)
* fix: Disabling of authorization providers with env

* fix: type error in authenticationProviders delete test

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:08:58 -04:00
Tom Moor 954946ae12 fix: Re-render Mermaid diagrams in light theme for print (#12342)
* fix: Re-render Mermaid diagrams in light theme for print

Mermaid diagrams are rendered as SVG with theme-specific colors baked in,
so when printing from dark mode the dark backgrounds carry through onto
white paper. Listen for beforeprint/afterprint and re-dispatch the
existing theme-changed event so the Mermaid plugin re-renders the
diagrams in light theme for the duration of the print, then restores
them after. The in-app print action also pre-dispatches the theme
change to give Mermaid's async render time to complete before the print
dialog captures the DOM.

* Refactor to use media query

---------
2026-05-13 21:14:42 -04:00
Tom Moor 4a324784be Refactor MCP tests (#12347) 2026-05-13 20:57:55 -04:00
Tom Moor 0d76dfc9f4 fix: Automatically expand code block if find result is within (#12346) 2026-05-13 17:18:16 -04:00
Tom Moor 9603e6d7c8 fix: Mermaid diagrams inside toggle containers do not render correctly (#12343) 2026-05-13 08:53:43 -04:00
Tor Anders Johansen e325867716 feat(mcp): add fullWidth parameter to create_document and update_document tools (#12338)
The fullWidth field is already persisted on the Document model and
supported in the REST API schemas (DocumentsCreateSchema, DocumentsUpdateSchema),
but the built-in MCP server did not expose it.

This patch adds fullWidth as an optional boolean parameter to both
create_document and update_document MCP tools, passing it through to
documentCreator and documentUpdater respectively.
2026-05-13 08:32:40 -04:00
Tom Moor bc6aa11f5d fix: Table cell selection should not show in print (#12334) 2026-05-13 08:24:49 -04:00
Tom Moor 7c070df942 fix: Correct locking in comment anchor for update (#12332)
* fix: Lock error on MCP anchorText comment creation

* refactor
2026-05-12 22:22:52 -04:00
Tom Moor 925a43bd36 feat: Inline comment support (#12322)
* feat: Inline comment support

* fix: wrap inline comment mark and creation in transaction with row lock

* fix: lock document row when anchoring, error on failed anchor, use uuid import
2026-05-12 21:22:38 -04:00
Tom Moor 6e99dff3c2 chore: Upgrade Mermaid (#12331) 2026-05-12 21:22:20 -04:00
Tom Moor 6bd775eb84 fix: Batch document deletes when emptying trash (#12328)
* fix: Batch document deletes when emptying trash

Splits the final parentDocumentId clear and destroy in documentPermanentDeleter
into chunks of 100 to keep the exclusive lock window on the documents table
short, preventing concurrent web SELECTs from queueing behind a single large
DELETE.

* fix: Skip parentDocumentId clear for documents restored mid-flight

Re-checks deletedAt in the database before clearing parentDocumentId on
children, so a parent restored between the caller's query and now keeps its
children attached.
2026-05-12 20:31:12 -04:00
Tom Moor 42a0958322 test: Fix flaky comments.list ordering assertion (#12329)
Two comments built back-to-back could share a millisecond-precision
createdAt, leaving the DESC-ordered result non-deterministic. Pass
explicit createdAt values so the assertion on body.data[1] is stable.
2026-05-12 20:03:54 -04:00
Tom Moor 935e0bb7b9 chore: Fix all no-misused-spread lint warnings (#12327) 2026-05-12 17:30:08 -04:00
Tom Moor 871cb52a23 fix: Print includes extra blank page (#12326)
closes #12324
2026-05-12 12:15:25 +00:00
Tom Moor 58f031c7e9 fix: Crash on misconfigured file storage env (#12325)
closes #12323
2026-05-12 12:07:08 +00:00
dependabot[bot] fc01deeefd chore(deps-dev): bump oxlint-tsgolint from 0.14.2 to 0.22.1 (#12320)
* chore(deps-dev): bump oxlint-tsgolint from 0.14.2 to 0.22.1

Bumps [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) from 0.14.2 to 0.22.1.
- [Release notes](https://github.com/oxc-project/tsgolint/releases)
- [Commits](https://github.com/oxc-project/tsgolint/compare/v0.14.2...v0.22.1)

---
updated-dependencies:
- dependency-name: oxlint-tsgolint
  dependency-version: 0.22.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

* chore: Switch tsconfig to bundler resolution for tsgolint 0.22.1

oxlint-tsgolint 0.22.1 removed support for moduleResolution=node10
(the alias for "node"). Switch to "bundler" with resolvePackageJsonExports
disabled so packages whose exports field omits a types condition still
resolve. Update markdown-it type imports to sub-paths since the package's
.d.mts entry only re-exports a subset of named types.

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

* fix: Resolve type-aware lint errors caught by tsgolint 0.22.1

oxlint-tsgolint 0.22.1 catches several await-thenable, no-floating-promises,
and no-meaningless-void-operator cases the prior 0.14.2 missed:

- Drop redundant inner `await` from Promise.all([await x, await y]) call sites
  so the array entries are real Promises rather than already-resolved values.
- Replace Promise.all wrappers around synchronous presenters (presentEvent,
  presentTemplate, presentPublicTeam) with plain map / direct calls.
- Wrap non-promise branches of ternaries inside Promise.all with
  Promise.resolve so the array remains thenable across both arms.
- Add `void` to the unawaited provider.connect() in the auth-failed retry
  chain, and remove `void` from the disconnect() call which returns void.

Co-Authored-By: Claude Opus 4.7 <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.7 <noreply@anthropic.com>
2026-05-12 07:59:13 -04:00
Tom Moor 3109f49b40 feat: Allow MCP to access signed attachment urls through fetch tool (#12315)
* feat: Add ability for MCP to access signed attachment urls through fetch tool

* Potential fix for pull request finding

* fix: non-admin cannot fetch attachments
2026-05-11 22:16:20 -04:00
dependabot[bot] dab06d4dfa chore(deps): bump i18next-fs-backend from 2.6.4 to 2.6.5 (#12319) 2026-05-11 20:19:11 -04:00
dependabot[bot] dcddab47e1 chore(deps): bump koa-compress from 5.1.1 to 5.2.1 (#12318) 2026-05-11 20:17:56 -04:00
dependabot[bot] 0eee576b81 chore(deps): bump the aws group with 5 updates (#12317) 2026-05-11 20:17:07 -04:00
Tom Moor 51a1d3bf50 perf: Cache decorations in editor plugins (#12030)
Avoid full document traversal on every keystroke by mapping decorations
through the transaction when no relevant nodes changed. Uses
changedDescendants to detect when a heading, image, or code_inline-marked
text actually changes; otherwise the existing DecorationSet is mapped to
new positions cheaply.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:09:57 -04:00
Tom Moor ff3b3ce552 fix: Allow empty string in optional MCP fields (#12310)
* fix: Allow empty string in optional fields

* fix: Preserve empty strings for content fields in MCP tools

Address review feedback by reverting content/text fields (description, document
text, comment text) back to z.string().optional() so callers can intentionally
clear values via "". optionalString() is reserved for identifier and query
fields where "" is not a meaningful input.
2026-05-10 10:47:24 -04:00
Tom Moor ab42e4fda8 chore(deps): Remove js-yaml resolution that no longer prevents downgrades (#12309)
The "js-yaml": "^4.1.1" resolution is now a no-op — every package that
requests js-yaml in the dep graph already asks for ^4.1.0 or ^4.1.1, both
of which naturally resolve to 4.1.1. Removing the resolution does not
change any installed version.

Audited the remaining resolutions; all still prevent a lower version from
being installed (or are intentional dedupe pins for @types/* and
prosemirror-transform per #12304, plus the i18next-parser compatibility
pin from #12307).

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-10 09:32:30 -04:00
Tom Moor 2cb47aa421 chore(deps): Bump i18next-parser to 9.4.0 to fix pre-commit hook (#12307)
i18next-parser 8.13.0 used a default import for cheerio, which broke
when cheerio dropped its default export. 9.x switched to a namespace
import. Pin the parser's transitive i18next to ^23.16.8 so plural keys
continue to be emitted in compatibilityJSON v3 format expected by the
runtime (i18next 22.5.1).
2026-05-09 13:53:45 -04:00
Tom Moor 7ff1c84530 chore: Short-circuit common scanner/crawler routes (#12306)
* Shortcircuit common scanner/crawler routes

* PR feedback, remove query strings
2026-05-09 11:32:17 -04:00
dependabot[bot] fba1bcef87 chore(deps): bump hono from 4.12.16 to 4.12.18 (#12305)
Bumps [hono](https://github.com/honojs/hono) from 4.12.16 to 4.12.18.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.16...v4.12.18)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 09:09:36 -04:00
Tom Moor 4548fc00bf chore(deps): Remove resolutions that no longer prevent downgrades (#12304)
* chore(deps): Remove resolutions that no longer prevent downgrades

Audited each resolution by removing it and running yarn install to check
whether any package would resolve to a lower version. Removed 31 entries
that were no-ops because the natural resolution already satisfies (or
exceeds) the resolution target — caret ranges that npm now publishes a
matching or higher version for, and one fast-xml-parser pin where the
underlying dependency moved.

Kept 13 entries: those that still prevent a regression, plus the @types/*
and prosemirror-transform pins that exist to dedupe transitive copies
against the project's own pinned versions.

* chore(deps): Bump @babel/preset-env to 7.29.5 to address GHSA-fv7c-fp4j-7gwp

@babel/plugin-transform-modules-systemjs <=7.29.3 generates arbitrary
code when compiling malicious input. Upgrading @babel/preset-env to
^7.29.5 brings in the patched ^7.29.4 transitively.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 09:02:50 -04:00
Tom Moor 7a75433bdc fix: Move OIDC redirect to useEffect (#12301) 2026-05-09 08:42:53 -04:00
Salihu b4cbb39f17 feat: request document access (#10825)
* feat: Request document access

Allow users without permission to a document to request access. Notifies
document managers via in-app notification and email; managers can grant
or dismiss the request.

- Adds AccessRequest model, migration, policy, presenter
- Adds accessRequests.create/info/approve/dismiss endpoints
- Adds DocumentAccessRequestNotificationsTask + email
- Adds Error403 request flow with loading state and pending indicator
- Auto-opens notifications popover via ?notifications=true (used in email)
- Adds SplitButton primitive for permission selection in notifications
- Refactors useConsumeQueryParam hook

* refactor

* fix: Make approve/dismiss idempotent on access requests

Return success when the access request has already been dismissed, or
when the user already has document membership at approve time, instead
of throwing 400. Avoids racy double-clicks on notification actions
producing user-visible errors.

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

* Minor fixes

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 08:42:47 -04:00
Tom Moor 56c3267186 Update popularity scoring to use document_insights table as data source (#12103)
* Update popularity scoring to use document_insights as data source

* Use UTC dates and guard against future-dated insights

Derive threshold/today as UTC day boundaries to match how document_insights.date is written, and add an upper bound to prevent future-dated rollups from collapsing the decay denominator.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 23:22:00 -04:00
Tom Moor 3670a918bb chore: Catch other types of client aborted error (#12303)
* chore: Catch other types of client aborted error

* Add EPIPE
2026-05-08 22:32:54 -04:00
Mark Steward fa990a33c0 Only preconnect to S3 if it's being used (#12298) 2026-05-08 14:41:32 -04:00
dependabot[bot] 8248fafe70 chore(deps): bump fast-uri from 3.1.0 to 3.1.2 (#12300)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 14:41:20 -04:00
dependabot[bot] a346e6dee6 chore(deps): bump fast-xml-builder from 1.1.5 to 1.1.8 (#12299)
Bumps [fast-xml-builder](https://github.com/NaturalIntelligence/fast-xml-builder) from 1.1.5 to 1.1.8.
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-builder/blob/main/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-builder/compare/v1.1.5...v1.1.8)

---
updated-dependencies:
- dependency-name: fast-xml-builder
  dependency-version: 1.1.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 14:32:14 -04:00
Tom Moor 571463710e Add support for data uri for images (#12294)
* Add support for data uri for images

* hoist
2026-05-08 09:10:32 -04:00
Tom Moor 9c26535815 Auto-subscribe mentioned users to document (#12235)
* Auto-subscribe mentioned users to documnet

* Add tests for mention auto-subscribe and a buildMention factory

* Add tests that prior unsubscribes are respected when mentioned

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

* Batch mention subscriptions into a single transaction

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:33:55 -04:00
Tom Moor 8371d709dd fix: Regression in client plugins not loading (#12291) 2026-05-07 15:44:30 -04:00
Tom Moor 4aa1c3289a chore: Add MCP user flag (#12290)
* Add MCP flag

* PR feedback
2026-05-07 08:34:54 -04:00
Tom Moor 87029a3ad7 chore(deps): bump ip-address to 10.2.0 to address XSS advisory (#12286)
* chore(deps): bump ip-address to 10.2.0 to address XSS advisory
2026-05-07 08:22:07 -04:00
Tom Moor d02659d325 chore: Remove stale Jest references from docs and comments (#12285)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 21:55:07 -04:00
Tom Moor 091346dfe8 chore: Migrate to vitest (#12272)
* wip

* Remove obsolete snapshots

* simplify

* chore(test): Convert mocks to TypeScript and tighten fetch mock types

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

* Remove unneccessary patches

* Migrate to msw instead of custom fetch mock

* Address PR review comments

- Split chained vi.useFakeTimers().setSystemTime() into separate calls.
- Switch test setup to dynamic imports so EventEmitter.defaultMaxListeners
  assignment runs before module init (static imports were hoisted above it).
- Drop redundant NODE_ENV guard in monkeyPatchSequelizeErrorsForJest; its
  sole caller already gates on env.isTest.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 21:10:51 -04:00
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00
Tom Moor 4387f3ced7 fix: Resolve console warnings for rtl DOM attribute and untracked MobX read (#12284)
- Use transient `$rtl` prop on Meta styled component so it isn't forwarded to the DOM
- Wrap ActionButton in observer so action visibility checks read MobX computed values inside a reactive context

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:48:13 -04:00
dependabot[bot] 9ddb57f1d3 chore(deps): bump hono from 4.12.12 to 4.12.16 (#12283)
Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.16.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.12...v4.12.16)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 20:34:40 -04:00
Tom Moor ad7e6c98ab chore: Vendor request-filtering-agent (#12266)
* chore: Vendor request-filtering-agent

* fix: honor fetch timeout and undefined allow list in proxy pre-flight

Default allowIPAddressList to [] so an unset ALLOWED_PRIVATE_IP_ADDRESSES
env var doesn't overwrite the agent's default and crash on .length, and
race the pre-flight DNS lookup against the request's abort signal so the
configured fetch timeout applies to slow DNS resolution.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:26:52 -04:00
Tom Moor 0f3f7b8da7 refactor: Remove useDictionary hook in favor of i18next t directly (#12282)
Plumbed `dictionary` props through editor components, menus, extensions,
and nodes. Replaces with `useTranslation()` in React contexts and direct
`t` imports from i18next elsewhere.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:24:50 -04:00
Tom Moor 4883071059 Allow viewers to create and access API keys (#12278)
* Allow viewers to create and access API keys

Still guarded by their view permissions

* Drop Member role gate from apiKeys routes

Lets viewers reach the createApiKey/listApiKeys/delete policy checks now
that the policy itself permits them. Updates CleanupDemotedUserTask to
retain viewer keys and adds coverage for viewer create + guest reject.

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

* Tighten apiKeys test assertions and broaden viewer coverage

- Use not.toBeNull() instead of toBeTruthy() for retention check
- Add viewer coverage for apiKeys.list and apiKeys.delete

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:35:59 -04:00
Tom Moor e0bc08478d feat: Add system preference to open desktop app on startup (#12279)
* feat: Adds toggle to open desktop app on startup

* Remove name hardcoding

* refactor
2026-05-06 19:29:42 -04:00
Tom Moor cc25790c81 Add mobile drawer support to notifications popover (#12276)
* fix: Open notifications in a bottom drawer on mobile

Match the mobile context menu pattern by rendering the notifications
panel as a Vaul bottom drawer below the tablet breakpoint, while
keeping the existing Radix popover on desktop.

* fix: Notification drawer opens at correct height on mobile

Skip the height animation while bounds is unmeasured to avoid a
feedback loop between framer-motion's animation toward 0 and the
ResizeObserver re-targeting it. Eagerly import Notifications so first
paint has real content for the initial measurement, and bump its
minHeight to 75vh on mobile to match other bottom drawers.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-06 07:42:53 -04:00
Tom Moor 41031aa7e6 Optimize icon picker for mobile with responsive sizing (#12275)
* Increase emoji picker cell size on mobile

Mobile uses a 40px button with a 32px emoji glyph (vs. 32px / 24px on
desktop), so roughly 8 emojis fit across a typical phone screen for
easier touch targeting.

https://claude.ai/code/session_017Rrv75Rc6eZ7eb2iNpZxpu

* tweaks

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-05 23:37:15 -04:00
Tom Moor 913322c0d5 fix: Search highlights not rendering in Firefox (#12273)
@emotion/stylis compiles top-level `::highlight(name)` rules inside a
styled component as a compound selector `.parent::highlight(name)`,
which only matches highlights on the editor container element itself.
Chrome applies these leniently, but Firefox correctly requires the
originating element to contain the highlighted text. Prefixing with
`&` forces a descendant combinator so descendant elements containing
the highlighted text are matched.

Closes #12270
2026-05-05 17:44:10 -04:00
Tom Moor 3562056d72 Reduce minimum table col width to 25px (#12269) 2026-05-05 08:40:36 -04:00
Tom Moor e7623eeade fix: Cannot select doc text in version history (#12268)
* fix: Cannot select doc text in version history

* PR feedback, use deco cache
2026-05-05 08:35:26 -04:00
Tom Moor 0df6c4947a chore(test): Performance (#12267)
* chore(test): drop no-op per-test Redis flushall

The afterEach created a fresh ioredis-mock client and flushed it, which
doesn't clear state held by clients elsewhere in the test. Removing the
hook saves a few ms across thousands of test cases.

* Cache Jest transform cache
2026-05-05 08:26:55 -04:00
Tom Moor a7c95b8d7e chore(ci): Parallelize jobs and remove serial setup gate (#12265)
Drop the dedicated setup job that blocked every other job for ~60s,
extract the install steps into a reusable composite action, drop the
unnecessary bundle-size dependency on types, and switch test-server
sharding to Jest's native --shard flag.
2026-05-05 07:50:53 -04:00
dependabot[bot] 77aee86c01 chore(deps): bump prosemirror-changeset from 2.3.1 to 2.4.1 (#12261)
* chore(deps): bump prosemirror-changeset from 2.3.1 to 2.4.1

Bumps [prosemirror-changeset](https://github.com/prosemirror/prosemirror-changeset) from 2.3.1 to 2.4.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-changeset/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-changeset/commits)

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

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

* fix: ExtendedChange type for prosemirror-changeset 2.4.1

The new Change class adds a toJSON() method, which broke `extends Change`
since ExtendedChange values are built via object spread and have no
prototype methods. Pick only the data properties instead.

Co-Authored-By: Claude Opus 4.7 <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.7 <noreply@anthropic.com>
2026-05-04 21:11:09 -04:00
dependabot[bot] bee5945c0b chore(deps-dev): bump @types/markdown-it from 14.1.1 to 14.1.2 (#12260)
* chore(deps-dev): bump @types/markdown-it from 14.1.1 to 14.1.2

Bumps [@types/markdown-it](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/markdown-it) from 14.1.1 to 14.1.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/markdown-it)

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

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

* fix: Drop removed `jump` field from mark delimiter

@types/markdown-it 14.1.2 removed `jump` from the `Delimiter` interface
to match upstream markdown-it, which tracks jumps in a local array
inside balance_pairs rather than on each delimiter.

Co-Authored-By: Claude Opus 4.7 <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.7 <noreply@anthropic.com>
2026-05-04 21:10:58 -04:00
dependabot[bot] 1f8f708c83 chore(deps): bump @bull-board/api from 6.21.2 to 6.21.3 (#12259)
* chore(deps): bump @bull-board/api from 6.21.2 to 6.21.3

Bumps [@bull-board/api](https://github.com/felixmosh/bull-board/tree/HEAD/packages/api) from 6.21.2 to 6.21.3.
- [Release notes](https://github.com/felixmosh/bull-board/releases)
- [Changelog](https://github.com/felixmosh/bull-board/blob/master/CHANGELOG.md)
- [Commits](https://github.com/felixmosh/bull-board/commits/v6.21.3/packages/api)

---
updated-dependencies:
- dependency-name: "@bull-board/api"
  dependency-version: 6.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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

* chore(deps): bump @bull-board/koa to 6.21.3

Aligns koa adapter's nested @bull-board/api with the top-level 6.21.3
to fix a TS2322 error from divergent BaseAdapter types.

Co-Authored-By: Claude Opus 4.7 <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.7 <noreply@anthropic.com>
2026-05-04 21:10:44 -04:00
dependabot[bot] b3500c2cad chore(deps): bump axios from 1.15.0 to 1.15.2 (#12262)
Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.15.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 21:01:40 -04:00
dependabot[bot] ac4dc014d5 chore(deps-dev): bump @babel/preset-env in the babel group (#12257)
Bumps the babel group with 1 update: [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env).


Updates `@babel/preset-env` from 7.29.2 to 7.29.3
- [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.3/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-version: 7.29.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 20:42:46 -04:00
dependabot[bot] b4fe88ba98 chore(deps): bump @dotenvx/dotenvx from 1.61.0 to 1.64.0 (#12258)
Bumps [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) from 1.61.0 to 1.64.0.
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.61.0...v1.64.0)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.64.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 20:42:33 -04:00
Tom Moor 4058b54573 fix: Relative path returned from MCP (#12255)
* fix: relative path returned from MCP

* fix: MCP create_attachment uploadUrl and size validation

Make uploadUrl absolute against team.url so MCP clients can resolve it
without a base, tighten the size schema to match the REST endpoint
(int, nonnegative, finite), and stub cookies on the MCP API context so
LocalStorage's CSRF-aware getPresignedPost works for Bearer-authed
MCP requests. Adds tests covering the success path, persistence, size
limits, schema rejections, and read-only scope enforcement.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 07:52:32 -04:00
Tom Moor 04a13de0e7 v1.7.1 2026-05-03 21:39:25 -04:00
Tom Moor cc2427492b fix: Various small layout issues with mobile 2026-05-03 20:44:11 -04:00
Tom Moor 61709ea42e fix: Minor warnings on login screen (#12250) 2026-05-03 08:34:52 -04:00
Tom Moor f50bb00b29 Refactor of OAuth account linking flows (#12246)
* Refactor of OAuth account linking flows

* PR feedback
2026-05-02 18:54:38 -04:00
Tom Moor 8c716b173a chore: Update editor generics (#12247)
* chore: Update editor generics

* fix: Address PR review on editor generics

- Restore null-guard on Link click handler so anchors aren't inert when no onClickLink is provided
- Mark onClickLink optional in LinkOptions and openLink command to match runtime
- Remove dead `collapsed` option from HeadingOptions
- Make ToggleBlock dictionary optional and restore optional-chained access for server-side schema instantiation

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:54:27 -04:00
Tom Moor cae8c78eb9 feat: Add delete_document and delete_collection MCP tools (#12245)
* feat: Add delete_document and delete_collection MCP tools

Adds MCP tools for deleting (or archiving) documents and collections.
Refactors Document#delete into destroyWithCtx and extracts collection
archive logic into Collection#archiveWithCtx so the same code paths can
be shared between the REST API and MCP entry points.

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

* fix: Wrap MCP delete tools in DB transaction

Ensures delete/archive of documents and collections via MCP is atomic
and that row locks (transaction.LOCK.UPDATE) inside *WithCtx methods
actually apply, matching the pattern used by move_document.

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

* docs: Clarify delete_collection MCP tool description

Reflects that collection deletion only soft-deletes non-archived
documents via the BeforeDestroy hook.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 12:29:22 -04:00
Tom Moor fca10221b9 chore: promote no-explicit-any from warn to error (#12244)
* chore: promote no-explicit-any from warn to error and resolve violations

Upgrades the oxlint rule severity and removes all 40 existing
`no-explicit-any` warnings across the codebase. Most call sites gained
proper types (SharedEditor refs, JSONNode/JSONMark for ProseMirror JSON
walking, DocumentsStore, dd-trace `Span` parameter inference, prosemirror
Fragment public API in place of internal `(fragment as any).content`).
A few load-bearing `any` uses were preserved with scoped disable
comments where changing the type would cascade widely (Sequelize JSONB
columns on `Event`, the `withTracing` higher-order function generic,
`Extension.options` consumed by many subclasses, dd-trace's `req`
patching).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 12:14:23 -04:00
Tom Moor f270611505 Add title guidance for MCP (#12242)
* Add title guidance for MCP

* Scope H1 guidance to documents only
2026-05-02 09:54:13 -04:00
Tom Moor 269e7d048f chore: resolve lint warnings in oauth routes and BaseStorage (#12243)
Replaces `as any` casts when constructing OAuth2Server Request/Response
with explicit objects containing the fields the library actually
consumes, and switches BaseStorage's manual header spread to a
node-fetch `Headers` instance to avoid the no-misused-spread warning.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 09:53:57 -04:00
Tom Moor 01f2643044 Add summary to MCP response where available (#12241) 2026-05-02 09:27:38 -04:00
Tom Moor 8a896ddd2d feat: Return breadcrumb in MCP responses (#12203)
* feat: Add breadcrumb to MCP responses

* test: Update MCP test expectations for new response envelope

Tests were reading the old flat document shape; update them to read
through the new { document, breadcrumb } envelope.

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

* perf: Batch collection lookups when building breadcrumbs

Add getBreadcrumbsForDocuments helper that loads all referenced
collections in one query (with the user's memberships) and resolves
breadcrumbs from the per-collection cached documentStructure. Use it
in list_documents and move_document, replacing the per-document
Collection.findByPk that produced an N+1 query pattern.

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

* test: Add coverage for getBreadcrumbsForDocuments and parallelize doc + breadcrumb loads

Run presentDocument and getDocumentBreadcrumb concurrently in fetch,
create_document, and update_document so the breadcrumb lookup no
longer adds latency on top of the presenter.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:57:03 -04:00
Tom Moor 621089a364 fix: Improve validation on subscription creation endpoint (#12240) 2026-05-01 22:39:11 -04:00
Tom Moor 903ce856ec perf: Fix exhaustive dep warnings in editor resize hook (#12238) 2026-05-01 08:47:13 -04:00
Tom Moor 1f097b0fdd chore: resolve no-explicit-any lint warnings in plugins (#12237)
* chore: resolve no-explicit-any lint warnings in plugins

Replaces uses of `any` in the plugins directory with concrete types,
`unknown`, or structured type assertions, addressing the remaining
typescript-eslint(no-explicit-any) warnings flagged by oxlint.

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

* chore: address review feedback in GitLabIssueProvider

Drop trailing semicolon from log string and add early return in
`destroyNamespace` when neither `user_id` nor `full_path` is present
to avoid an unnecessary full-scan transaction.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 08:29:58 -04:00
Hemachandar e2c28f4b9f fix: Autofocus inside lazy-loaded modal and popover (#12146)
* fix: Autofocus inside lazy-loaded modal/popover

* use wrapperRef

* remove unused import
2026-05-01 08:15:14 -04:00
Tom Moor 1caf7f9221 chore: Increased default model creation rate limits from 10/m to 25/m (#12236) 2026-05-01 08:14:25 -04:00
Tom Moor eaca221fde fix: parseXML error in AWS SDK (#12231) 2026-05-01 02:56:10 +00:00
Tom Moor 4c2b62ef6a fix: should change lastModifiedById when republishing doc (#12227)
* fix: should change lastModifiedById when republishing an already published document

* test: Use same-team creator in republish attribution test

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 12:06:31 +00:00
Tom Moor 773750d470 Add RATE_LIMITER_MULTIPLIER configuration for self-hosted instances (#12226)
* Add RATE_LIMIT_MULTIPLIER configuration for self-hosted instances

* PR feedback
2026-04-30 11:49:45 +00:00
Tom Moor 6763ecbd5f fix: API keys with global read scope not being saved correctly (#12225)
* fix: Api keys with global read scope not being saved correctly

* refactor: Hoist global scopes Set to module level

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 07:48:49 -04:00
Tom Moor dbafd37751 chore: Add manual confirmation before build published (#12223) 2026-04-30 07:15:05 -04:00
Tom Moor f3f9b3e705 fix: Incorrect behavior of mod-left in heading node in FF (#12219) 2026-04-30 06:59:32 -04:00
Tom Moor 639d03f291 fix: Nesting and icon of doc shared to group 2026-04-29 23:04:42 -04:00
Tom Moor 69f46b182f fix: Handle invalid post-login redirect path in Firefox (#12218) 2026-04-29 20:16:31 -04:00
Tom Moor bac2b01abd perf: Refactor sidebar expanded state (#12215)
* fix: centralize sidebar expansion state to eliminate O(N²) tree traversals

Each DocumentLink previously traversed the full collection tree independently
to determine whether to auto-expand (pathToDocument / descendants), which is
O(N) per row and quadratic overall. With thousands of documents this makes
the sidebar unusable.

Replaces per-node expansion state with a single MobX-backed
SidebarExpansionState per tree root. The ObservableSet ensures only the
toggled node re-renders. Alt-click cascade, auto-expand on navigation,
and drag-to-reparent expansion all go through the same centralized state
instead of the per-node SidebarDisclosureContext relay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: move SidebarExpansionContext alongside other sidebar contexts

Rename hooks/useSidebarExpansion.ts to components/SidebarExpansionContext.ts
to match the convention of SidebarContext.ts and SidebarDisclosureContext.ts.
The context is now the default export with hooks as named exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: scope sidebar expansion to its own tree and restore alt-click cascade

`useSidebarExpansionState` was unconditionally adding the active document
id to every per-tree expansion set, which made `SharedWithMeLink` auto-
expand whenever the user navigated anywhere in the matching sidebar
context. `computeAncestorPath` now includes the target when found and
returns empty when absent, so the hook only expands ids that actually
belong to its tree.

Also restores alt-click cascade for `StarredDocumentLink` and
`SharedWithMeLink`: the parents still broadcast disclosure events but
`DocumentLink` no longer listens, so nested children weren't expanded.
`StarredDocumentLink` now subscribes via `useSidebarDisclosure` (mirroring
`CollectionLinkChildren`), and `SharedWithMeLink` calls
`expansion.expandAll`/`collapseAll` directly on alt-click.

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

* fix: collapse expanded nodes when children are removed and deduplicate shared expansion provider

Restores the effect that collapses a node in the expansion state when it
no longer has children, preventing the reorder drop logic from treating
leaf nodes as expanded containers. Also removes the redundant
SidebarExpansionContext.Provider from SharedCollectionLink since the
parent SharedSidebar already provides one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 19:28:53 -04:00
Tom Moor d2328b1763 fix: Add gap between search and actions in header (#12214) 2026-04-29 17:45:12 -04:00
Tom Moor 4c8a1c89b2 chore: resolve no-explicit-any and no-base-to-string lint warnings (#12217) 2026-04-29 17:45:02 -04:00
Tom Moor 281b778b2d fix: Suspended users should not be included in cached member count (#12197)
* fix: Suspended users should not be included in cached member count for groups

* fix: Defer CounterCache hook registration until model is initialized

The previous test-only no-op hid a timing bug where setImmediate could
fire before the Sequelize instance had registered the related model,
causing "Model not initialized" failures. Poll until the model is
ready, and unref the pending immediate so it does not keep the event
loop alive in environments where the database is never initialized.

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

* perf: Reduce overhead of group member count invalidation

Select only the groupId column with raw queries and de-duplicate before
issuing Redis deletes, avoiding loading full GroupUser rows into memory
when a user belongs to many groups.

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

* chore: unref Redis healthcheck interval

Don't keep the Node event loop alive solely for the periodic ping; the
event loop should drain on its own when the application is shutting
down or a Jest worker is finishing.

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

* refactor: Centralize counter cache key in RedisPrefixHelper

Avoid duplicating the "count:<Model>:<relation>:<id>" string between
the CounterCache decorator and the User suspension hook by routing
both through a single getCounterCacheKey helper.

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

* fix: Walk to parent transaction when scheduling cache invalidation

Nested savepoints commit independently of their outer transaction, so
afterCommit callbacks attached to the inner transaction may run after
the outer rolls back, or never run at all. Match the pattern used in
Collection, Event, and base/Model and walk to the parent transaction
so the cache invalidation fires after the real outer commit.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 11:24:44 -04:00
Tom Moor 0e074321db fix: Add additional validation to table attributes (#12156)
* fix: Add additional validation to table attributes

* fix: Widen isValidCellMarks predicate and test 4-digit hex

* Additional tests

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:40:04 -04:00
Tom Moor 49ca7d5e37 chore(deps): bump react-hook-form and vite-plugin-pwa (#12212)
- react-hook-form 7.69.0 → 7.74.0 (^7.72.1 range)
- vite-plugin-pwa 1.0.3 → 1.2.0
2026-04-29 08:25:01 -04:00
Tom Moor 57308c46af chore: resolve lint warnings (no-explicit-any, no-redundant-type-constituents, no-base-to-string) (#12209)
* chore: resolve no-redundant-type-constituents and test/mock no-explicit-any warnings

Clears 36 lint warnings: all 5 no-redundant-type-constituents, 6
no-misused-spread (via narrowing getPartitionWhereClause's return type
to WhereAttributeHash), and 25 no-explicit-any in test/mock files.

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

* chore: resolve no-base-to-string warnings in tests

Convert userProvisioner try/catch error assertions to Jest's
.rejects.toThrow() idiom, and cast webhook test body to string.

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

* chore: resolve no-explicit-any warnings in cancan and tracing

Tighten types in the cancan policy framework and tracing decorators.
Constructor / generic-function upper bounds keep `any` where TypeScript
variance requires it, scoped to single-line oxlint-disable comments.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:55:30 -04:00
Tom Moor f8e70c2c39 chore: resolve mechanical react-hooks/exhaustive-deps warnings (#12207)
Adds missing stable dependencies (e.g. `t`, prop callbacks, store refs,
`setFocusedCommentId`) and removes unnecessary ones across hooks where the
fix is straightforward. For the two MobX-observed `.orderedData` deps in
`History.tsx`, keeps the original deps and silences the false positive
with `eslint-disable-next-line` so the memos still recompute when the
underlying observable arrays change.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:06:09 -04:00
Tom Moor 4c85c4d08d chore: resolve unbound-method lint warnings in tests (#12204)
Capture jest mock references in local variables instead of asserting
against unbound method references on mocked classes/instances.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:50:25 -04:00
Tom Moor e29c9102af chore: resolve unbound-method warnings in NotionConverter (#12205)
Convert rich_text and rich_text_to_plaintext from static methods to
static arrow-function fields so they can be passed as map callbacks
without tripping the unbound-method lint. Neither method accesses
`this`, so behavior is unchanged.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:49:58 -04:00
Tom Moor f9a2cbc1b3 chore: resolve remaining unbound-method lint warnings (#12206)
* chore: resolve remaining unbound-method lint warnings

Apply targeted fixes per call pattern: arrow wrappers when passing a
method as a callback, arrow-function class fields when the method
doesn't depend on `this`, and `.bind()` when capturing for later
invocation.

Also replaces the rfc6902 hasOwnProperty re-export with a small wrapper
function so callers don't reference an unbound prototype method.

* chore: memoize history.goBack callbacks

Stable identity prevents Button re-renders and avoids re-subscribing
the global keydown handler in RegisterKeyDown when the parent renders.
2026-04-28 20:49:35 -04:00
Tom Moor 87bb79250d chore: enable typescript/restrict-template-expressions lint rule (#12199)
* chore: enable typescript/restrict-template-expressions lint rule

Coerce values of unknown type with explicit String() and tighten typing
for template literal expressions across the codebase.

* fix: restore --line-height on Card for fadeOut ::after gradient
2026-04-28 20:11:15 -04:00
Tom Moor adbffc0734 chore: clear mechanical lint warnings (Phase 1) (#12198)
* chore: clear mechanical lint warnings

Drops 44 oxlint warnings (559 → 515) by fixing easy mechanical rules
across the codebase: no-useless-escape, no-duplicate-type-constituents,
no-redundant-type-constituents, no-unused-expressions,
no-meaningless-void-operator, require-array-sort-compare, await-thenable.

* chore: drop callback parameter from useCallback deps

The `open` argument is a parameter of the callback, not a closed-over
variable, so it doesn't belong in the deps array.

* chore: promote cleared lint rules to errors

Promotes the rules cleared in this PR from warn to error so future
violations fail the lint:

- no-unused-expressions
- typescript/await-thenable
- typescript/no-duplicate-type-constituents
- typescript/no-meaningless-void-operator
- typescript/require-array-sort-compare

Removes the override that suppressed no-useless-escape on source
files (the global rule is already error) and fixes the 21 escape
violations that this exposed in regex character classes and template
literals.

* chore: address PR review feedback

- usePinnedDocuments: simplify UrlId to plain string instead of the
  intersection trick.
- PlantUML embed: move - to end of character class so it's a literal
  hyphen rather than a range operator.
- checkboxes: type token params as Token | undefined to match the
  actual call sites that pass tokens[index - 2] etc.
2026-04-28 20:00:03 -04:00
Tom Moor cd9e79b1f1 chore: replace explicit any with concrete types in shared (#12201)
* chore: replace explicit any with concrete types in shared

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

* chore: address review feedback

- naturalSort: guard non-string field values instead of asserting string
- ProsemirrorHelper: type stored mark attrs as Partial<CommentMark>
- env: revert to Record<string, any>; safer typing requires fixing many consumers

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:51:51 -04:00
Tom Moor 5610df5a26 chore: Reduce no-explicit-any warnings in server directory (#12202)
* chore: Reduce no-explicit-any warnings in server directory

Tightens types across test response bodies, decorator signatures, the
TestServer wrapper, base class generics, and presenter Record types.
Where any is genuinely load-bearing (Sequelize model generics,
PropertyDescriptor decorator returns, plugin-registered template
classes, Fix mixin), keeps any with a targeted eslint-disable plus
reason rather than masking the constraint. Cuts server-only
no-explicit-any warnings from 162 to 70.

* fix: groups test asserts on first response instead of second

Caught by Copilot review on the no-explicit-any cleanup. Also fixes
the pre-existing getChangsetSkipped → getChangesetSkipped typo
surfaced while reviewing nearby decorator code.
2026-04-28 19:50:45 -04:00
Tom Moor 9b7ccf8cb5 fix: Resolve no-floating-promises lint errors (#12196)
* fix: Resolve no-floating-promises lint errors

Adds await or void to 10 unhandled promises. Notable fixes: a test
assertion using `.resolves` was never awaited, and a custom emoji
fetch with setState was running during render instead of in an effect.

* chore: Promote no-floating-promises to error

Now that all occurrences are fixed, prevent regressions.
2026-04-28 18:13:46 -04:00
dependabot[bot] 816a474a46 chore(deps-dev): bump oxlint and tsgolint (#12127)
* chore(deps-dev): bump oxlint-tsgolint from 0.1.6 to 0.21.1

Bumps [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) from 0.1.6 to 0.21.1.
- [Release notes](https://github.com/oxc-project/tsgolint/releases)
- [Commits](https://github.com/oxc-project/tsgolint/compare/v0.1.6...v0.21.1)

---
updated-dependencies:
- dependency-name: oxlint-tsgolint
  dependency-version: 0.21.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

* chore: Adjust lint config for newer oxlint-tsgolint

Pin oxlint to 1.50.0 and oxlint-tsgolint to 0.14.2. Older oxlint
can't parse newer tsgolint diagnostic payloads, and tsgolint >=0.15
rejects moduleResolution: "node" — moving off it requires either
"bundler" (currently breaks @hocuspocus@1.1.3 typings, which lack a
types condition in their package.json exports) or "node16"/"nodenext"
(would require explicit .js extensions on every relative import).

Add per-package ignorePatterns since they no longer propagate from
the root config when nested configs are present.

Drop tsconfig baseUrl (typescript-go in tsgolint rejects it) and add
a plugins/* path alias so cross-plugin imports keep resolving. The
babel resolver is switched from babel-plugin-tsconfig-paths-module-
resolver (which required baseUrl) to babel-plugin-module-resolver
with explicit aliases.

---------

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>
2026-04-28 13:50:42 -04:00
Tom Moor af77bd6474 fix: Uncollapse code when printing (#12193)
closes #12192
2026-04-27 22:55:16 -04:00
dependabot[bot] c34567cb0a chore(deps): bump ioredis from 5.8.2 to 5.10.1 (#12190)
* chore(deps): bump ioredis from 5.8.2 to 5.10.1

Bumps [ioredis](https://github.com/luin/ioredis) from 5.8.2 to 5.10.1.
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.8.2...v5.10.1)

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

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

* dedupe

---------

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>
2026-04-27 21:02:42 +00:00
dependabot[bot] bf696581b7 chore(deps): bump @css-inline/css-inline-wasm from 0.18.0 to 0.20.2 (#12191)
Bumps [@css-inline/css-inline-wasm](https://github.com/Stranger6667/css-inline) from 0.18.0 to 0.20.2.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/v0.18.0...c-v0.20.2)

---
updated-dependencies:
- dependency-name: "@css-inline/css-inline-wasm"
  dependency-version: 0.20.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 16:54:39 -04:00
dependabot[bot] 9cc22df873 chore(deps): bump dd-trace from 5.82.0 to 5.98.0 (#12189)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.82.0 to 5.98.0.
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.82.0...v5.98.0)

---
updated-dependencies:
- dependency-name: dd-trace
  dependency-version: 5.98.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 16:54:06 -04:00
dependabot[bot] 5058ad3640 chore(deps): bump class-validator from 0.14.4 to 0.15.1 (#12188)
Bumps [class-validator](https://github.com/typestack/class-validator) from 0.14.4 to 0.15.1.
- [Release notes](https://github.com/typestack/class-validator/releases)
- [Changelog](https://github.com/typestack/class-validator/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/typestack/class-validator/compare/v0.14.4...v0.15.1)

---
updated-dependencies:
- dependency-name: class-validator
  dependency-version: 0.15.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-04-27 16:29:17 -04:00
dependabot[bot] 26049e35ab chore(deps): bump compressorjs from 1.2.1 to 1.3.0 (#12187)
Bumps [compressorjs](https://github.com/fengyuanchen/compressorjs) from 1.2.1 to 1.3.0.
- [Release notes](https://github.com/fengyuanchen/compressorjs/releases)
- [Changelog](https://github.com/fengyuanchen/compressorjs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fengyuanchen/compressorjs/compare/v1.2.1...v1.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 16:29:02 -04:00
dependabot[bot] 755bf03e8c chore(deps): bump pg from 8.16.3 to 8.20.0 (#12186)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.16.3 to 8.20.0.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.20.0/packages/pg)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 16:28:45 -04:00
Tom Moor c0521dbfd0 chore: Bump ajv to 8.18.0 to address GHSA-2g4f-4pwh-qvx6 (#12185)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 11:09:21 -04:00
Tom Moor 11d10bb67c chore: Remove redundant package resolutions (#12184)
Drops nine global resolutions whose versions are already produced by
yarn's natural resolution: @hocuspocus/server, fengari, d3, node-fetch,
socket.io-parser, @xmldom/xmldom, tar, @hono/node-server, and underscore.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 10:48:43 -04:00
Tom Moor 8e83544bb4 chore: Bump qs to 6.14.2 to address GHSA-w7fw-mjwx-w883 (#12183)
* chore: Bump qs to 6.14.2 to address GHSA-w7fw-mjwx-w883
2026-04-27 10:29:07 -04:00
dependabot[bot] a310358334 chore(deps): bump dottie from 2.0.6 to 2.0.7 (#12182)
Bumps [dottie](https://github.com/mickhansen/dottie.js) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/mickhansen/dottie.js/releases)
- [Commits](https://github.com/mickhansen/dottie.js/compare/v2.0.6...v2.0.7)

---
updated-dependencies:
- dependency-name: dottie
  dependency-version: 2.0.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 10:20:32 -04:00
Tom Moor 88d871e463 chore: Clear lodash _.template injection advisory from audit ignore list (#12180)
Pin lodash and lodash-es to ^4.18.1 via resolutions so transitive deps
pick up the patched versions, then drop the advisory ID.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 10:13:15 -04:00
Tom Moor e515fa5b44 chore: Bump fast-xml-parser to 5.7.0 to address GHSA-gh4j-gqv2-49f6 (#12181)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 10:13:04 -04:00
Tom Moor c280bddab9 chore: Clear picomatch ReDoS advisories from audit ignore list (#12179)
Pin picomatch to ^2.3.2 / ^4.0.4 via resolutions so transitive
deps pick up the patched versions, then drop the advisory IDs.
2026-04-27 09:48:03 -04:00
Tom Moor ec2463e5ff chore: Bump zod to 4.3.6 (#12178) 2026-04-27 09:33:44 -04:00
Tom Moor c6aacfeeeb chore: Clear minimatch ReDoS advisories from audit ignore list (#12177)
* chore: Resolve minimatch ReDoS advisories via dep bumps and resolutions

Bump glob (8→11), rimraf (2→6), babel-jest, jest-environment-jsdom (29→30),
and lint-staged (13→16) to drop several vulnerable transitive chains. Pin
remaining minimatch and brace-expansion descriptors via resolutions so all
in-tree copies are on their latest patched release. Removes 9 ignored
advisory IDs from .yarnrc.yml.

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

* fix: Make routeHelpers.urlify origin testable for jsdom 26

jsdom 26 (jest-environment-jsdom@30) makes window.location and
location.origin non-configurable, breaking the previous test that
redefined them via Object.defineProperty.

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

* chore: Align jest-cli to ^30.3.0 with other jest packages

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 09:13:52 -04:00
Tom Moor f111c8875e Tweak TOC positioning on share (#12171) 2026-04-26 22:52:10 -04:00
Tom Moor 3f07771a7e chore: Improve setup against supply chain attacks (#12170)
* Add npm audit CI
Remove postinstall
Disable postinstall scripts
Increase age gate to 3d

* audit cleanup

* Gate on dep changes
2026-04-26 21:23:26 -04:00
Copilot 7ed41eadc6 Add per-share branding: title and logoUrl overrides (#12003)
* feat: add title and logoUrl to Share model

Agent-Logs-Url: https://github.com/outline/outline/sessions/9bc9d438-6892-4903-9d32-6b6868f4fd97

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

* fix: use STRING(4096) for logoUrl column in migration

Agent-Logs-Url: https://github.com/outline/outline/sessions/9bc9d438-6892-4903-9d32-6b6868f4fd97

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

* feat: use share title and logoUrl to override team branding on shared page

Agent-Logs-Url: https://github.com/outline/outline/sessions/854d6d22-e80b-4673-b3b2-0f9cf43a3246

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

* refactor: use ShareValidation class constants for title/logoUrl max lengths

Agent-Logs-Url: https://github.com/outline/outline/sessions/ea462d6a-d4d3-4882-ab8e-88060bf64877

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

* fix: use ShareValidation constants in @Length msg template literals

Agent-Logs-Url: https://github.com/outline/outline/sessions/694116c2-47e8-4001-a103-c8a62c7ac71e

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

* feat: add display settings popover with custom title and icon for shares

Move share toggles (search indexing, email subscriptions, show last
modified, show TOC) into a popover triggered by a settings cog. The
popover also includes inputs for a custom site title and icon upload
to override team branding on shared pages. Rename logoUrl to iconUrl,
loosen URL validation to allow relative attachment paths, and surface
the popover in the shared page header for users with edit permission.

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

* styling

* Display branding on single shared pages

* Review comments

* refactor

* PR feedback

* Lose 'Remove icon' button

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:23:13 -04:00
Tom Moor 2e3ae72cd2 feat: Show toggle theme shortcut in keyboard shortcuts modal (#12168) 2026-04-26 13:54:56 -04:00
Tom Moor c4d764e243 feat: Show a chip in header with search term when highlighted in doc (#12165)
* feat: Show a chip in header with search term when highlighted in doc

* theme
2026-04-25 10:59:30 -04:00
Tom Moor b04002a009 Style refinement of switch input (#12164) 2026-04-25 10:22:31 -04:00
Tom Moor 22f5618465 chore: Upgrade socket.io-parser (#12053)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-04-25 09:15:36 -04:00
Translate-O-Tron 1d0a611ea3 New Crowdin updates (#12094)
* fix: New Ukrainian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Catalan translations from Crowdin [ci skip]
2026-04-25 08:52:46 -04:00
dependabot[bot] b5923569ee chore(deps): bump i18next-http-backend from 2.7.3 to 3.0.5 (#12137)
Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 2.7.3 to 3.0.5.
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/compare/v2.7.3...v3.0.5)

---
updated-dependencies:
- dependency-name: i18next-http-backend
  dependency-version: 3.0.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-25 08:48:54 -04:00
Tom Moor e034a28242 chore: Address AI code quality findings (#12163)
- Modal: translate default title and bind Dialog.Title to visible text
- Document Header: regroup imports and rename isNew -> wasNew
- Redis adapter: surface error.message and guard pingTimeout cleanup
- urls: fix typo and correct JSDoc @param names

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 08:25:55 -04:00
Tom Moor 9c2d8d9279 fix: Shift-Tab on list item inside toggle block outdents entire block (#12162)
Mirrors the v1.7.0 Tab fix by skipping the toggle block's dedent command
when the selection is inside a list, so the list's Shift-Tab handler can
outdent the list item instead.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 11:46:39 +00:00
Tom Moor f65389bd46 chore: Add Redis PING healthcheck (#12157)
* chore: Add Redis PING healthcheck

* PR feedback

* fix incorrect reconnects
2026-04-24 17:27:00 -04:00
dependabot[bot] 382dcf61f7 chore(deps): bump postcss from 8.5.6 to 8.5.10 (#12159)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 16:48:50 -04:00
Tom Moor 568b4ac074 v1.7.0 2026-04-24 20:19:52 +00:00
Tom Moor e59d7ee973 fix: Escape key should clear search highlight when editor does not have focus (#12158)
* fix: Escape key should clear search highlight when editor does not have focus

* PR feedback, CSS guard
2026-04-24 14:36:09 -04:00
Tom Moor f3f97cc3ea feat: Add hex swatch previews (#12150)
* feat: Add hex previews, closes #860

* PR feedback
2026-04-24 04:29:13 -04:00
Tom Moor 4c4649346b feat: Allow geo:, maps:, and magnet: link protocols (#12149)
* feat: Allow geo:, maps:, and magnet: link protocols

* Case-insensitive scheme matching, fix test grammar
2026-04-24 04:10:38 -04:00
Hemachandar 22538e7392 fix: Scrollbar flash in new collection modal (#12144)
* fix: Scrollbar flash in new collection modal

* reset animating on close
2026-04-24 13:23:40 +05:30
Tom Moor 1b0a5fb067 fix: TOC auto-closes, closes #12140 (#12143) 2026-04-23 05:02:55 -04:00
Tom Moor eefa8d4222 Add year headings to compare version select (#12138)
* Add year headings to compare version select

* Address review feedback on heading options

Use stable keys for heading options and set explicit displayName.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:04:35 -04:00
dependabot[bot] 5b2283386d chore(deps): bump i18next-fs-backend from 2.6.3 to 2.6.4 (#12136)
Bumps [i18next-fs-backend](https://github.com/i18next/i18next-fs-backend) from 2.6.3 to 2.6.4.
- [Changelog](https://github.com/i18next/i18next-fs-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-fs-backend/compare/v2.6.3...v2.6.4)

---
updated-dependencies:
- dependency-name: i18next-fs-backend
  dependency-version: 2.6.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 14:07:49 -04:00
Tom Moor ccbc9b75fc fix: Null reference (#12135)
* fix: Null reference

* fix: Scope image click querySelector to editor view

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:07:12 -04:00
Tom Moor 05da80d318 fix: Drag active links with children (#12133) 2026-04-22 13:07:08 -04:00
dependabot[bot] 26bc3fb1b8 chore(deps): bump @tanstack/react-virtual from 3.13.23 to 3.13.24 (#12128)
Bumps [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) from 3.13.23 to 3.13.24.
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.13.24/packages/react-virtual)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.13.24
  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-04-22 04:51:10 -04:00
Tom Moor bc982cb516 fix: Sentry for handled errors in MCP (#12130)
* fix: Sentry for handled errors in MCP

* refactor: Pass error object to Logger.warn in MCP transport handler
2026-04-22 04:50:57 -04:00
Tom Moor 733355f514 fix: Ignore Outlook SafeLink crawler errors in Sentry (#12131) 2026-04-21 19:34:49 -04:00
Tom Moor d55c9ccc1f fix: Reduce noise from XHR upload network errors (#12132)
Network-level upload failures (xhr.status === 0) now log as warnings
with extra context instead of unhelpful "Error: 0" reports in Sentry.
2026-04-21 19:34:39 -04:00
Tom Moor 1649b46778 fix: Incorrect nesting in publish dialog (#12122)
* fix: Incorrect nesting in publish dialog

* fix: Incorrect expanded disclosure background

* PR feedback
2026-04-20 19:34:25 -04:00
Tom Moor 276ae71a91 Various fixes (#12121) 2026-04-20 19:34:16 -04:00
dependabot[bot] 4e07cf75bf chore(deps): bump the aws group with 5 updates (#12124)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.990.0` | `3.1032.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.990.0` | `3.1032.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.990.0` | `3.1032.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.990.0` | `3.1032.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.990.0` | `3.1032.0` |


Updates `@aws-sdk/client-s3` from 3.990.0 to 3.1032.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1032.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.990.0 to 3.1032.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1032.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.990.0 to 3.1032.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1032.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.990.0 to 3.1032.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1032.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.990.0 to 3.1032.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1032.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 19:34:05 -04:00
dependabot[bot] a422c537ec chore(deps): bump @simplewebauthn/browser from 13.2.2 to 13.3.0 (#12125)
Bumps [@simplewebauthn/browser](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/browser) from 13.2.2 to 13.3.0.
- [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.0/packages/browser)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/browser"
  dependency-version: 13.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 19:33:47 -04:00
Tom Moor 1b91a295e1 fix: Use verified JWT for rate limiting (#12114)
* fix: Use verified JWT for rate limiting

* PR feedback

* Prefer guards
2026-04-20 06:19:39 -04:00
github-actions[bot] 06d5969099 fix: Update Node.js to 24.15.0 (#12120)
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-04-20 06:19:11 -04:00
Tom Moor 321b232f17 Move "Webhook" settings to table (#12119)
* Move 'Webhook' settings to table

* Add tests
2026-04-19 19:27:32 -04:00
Tom Moor 69e8aac4f1 Move "Api Keys" listing to filterable table (#12117)
* Move 'Api Keys' listing to filterable table

* Add context menu
Allow copying new keys
2026-04-19 18:12:32 -04:00
Tom Moor 7b182f9038 More styling improvements to highlight control 2026-04-19 18:07:08 -04:00
Tom Moor c52c96dc96 perf: Remove unneccesary location subscription (#12116) 2026-04-19 16:18:52 -04:00
Tom Moor ce409c0a8a fix: Return to empty search on 'Search' sidebar click (#12115)
* fix: Return to empty search on 'Search' sidebar click

* PR feedback
2026-04-19 15:53:19 -04:00
Tom Moor 666b3879b3 feat: Document history design (#12112)
* refactor

* refactor

* design
2026-04-19 09:37:09 -04:00
Tom Moor 46b040a9f4 fix: Validate move operation path prefix per RFC 6902 (#11835)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 21:08:39 -04:00
Tom Moor 36f6cb9e01 fix: Do not clear local database on passive logout (#12109) 2026-04-18 20:26:32 -04:00
Robert Hawkins 182f7f38f6 feat: Allow comparing any two revisions in document history (#12001)
* feat: Allow comparing any two revisions in document history

* Copilot review feedback
Move MobX store lookup out of useMemo so it stays reactive, fix i18n key spacing to match existing translations, and map synthetic latest revision ID to "latest" in the dropdown so DataLoader can fetch it.

* fix: Force editor remount when comparison target changes

* fix: Don't show wrong diff while compareTo revision is loading
2026-04-18 15:13:57 -04:00
Tom Moor 49d5052a51 feat: RTL layout (#12107)
* First pass

* Remove prop drilling, fix comment layout

* Revert dev:watch to use dev:backend

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:12:57 -04:00
Tom Moor e6cfc45fb4 chore: Upgrade xmldom (#12108) 2026-04-18 19:04:17 +00:00
Tom Moor b90659d8c1 fix: Remove user id from toggle storage key (#12105)
* fix: Remove user id from toggle storage key

* refactor: Namespace toggle fold storage key

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 19:01:33 +00:00
Tom Moor c02ac30eb0 refactor: Convert Document scene to functional component (#12033)
* refactor: Convert Document scene from class to functional component

Replace the @observer class component with a functional component using
hooks (useStores, useTranslation, useHistory, useLocation) instead of
HOC wrappers (withStores, withTranslation, withRouter). All @observable
state converted to useState with companion refs for stale closure
avoidance in debounced callbacks and unmount cleanup.

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

* refactor: Extract save/dirty tracking into useDocumentSave hook

Moves all save, autosave, dirty-tracking, template insertion, and
unmount cleanup logic from DocumentScene into a dedicated hook. This
reduces the component from ~790 to ~500 lines and isolates re-renders
from save state changes (isSaving, isPublishing, etc.) to a smaller
surface.

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

* docs: Add JSDoc to DocumentScene Props and function

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

* unused

* Remove withStores

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 11:33:59 -04:00
Tom Moor 8535f2c092 chore: Refactor WebsocketProvider to functional component (#12034)
* chore: Refactor WebsocketProvider to functional component

* refactor
2026-04-18 11:05:30 -04:00
Tom Moor 267835ce6f Add missing controls to starred documents (#12100)
* Add missing controls to starred documents

* refactor

* refactor

* fix: Enter does not submit

* fix: Reordering child docs in starred section

* refactor: Rename editTitle to labelText, remove non-null assertion

* Refactor draggable for consistency

* refactor

* Remove star icon

* fix: Allow drag and drop importing into starred

* tsc
2026-04-18 11:04:05 -04:00
Tom Moor 60562f4f6a fix: Handle GitLab Flavored Markdown (#11930)
* fix: Handle GitLab Flavored Markdown

* PR feedback

* Harden HTML comment stripping against overlapping patterns

Loop the replacement until stable to avoid CodeQL's incomplete
multi-character sanitization alert — a single pass could leave
`<!--` residue for inputs like `<!<!-- x -->-- -->`.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 10:53:50 -04:00
Tom Moor 600108bc43 feat: Document insight rollups (#12086)
* First pass

* Remove popularity changes

* Address review feedback

- Compute retention cutoff in UTC from the database rather than worker-local TZ
- Push partition predicate into rollup source CTEs to avoid full-table scans per partition

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

* Anchor insight rollups to UTC and include today

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 08:11:15 -04:00
Tom Moor 6d7d8b056c fix: trimFilenameAndExt should not be passed full path (#12101) 2026-04-18 08:10:30 -04:00
Tom Moor 5cb4b71652 feat: Improve MCP ability to read tree hierarchy (#12102)
* feat: Improve MCP ability to read tree heirarchy

* PR feedback
2026-04-18 08:09:55 -04:00
Tom Moor 4dd24b59ad fix: Validate that shares contain only a documentId or collectionId (#12098)
* fix: Validate that shares contain only a documentId or collectionId

* Restore test
2026-04-18 03:29:20 +00:00
Tom Moor 04debcb607 fix: Disallow invalid scopes (#12099) 2026-04-18 03:26:08 +00:00
Tom Moor 505082b196 fix: Correctly validate uploaded file size using "local" storage option (#12095)
* fix: Correctly validate uploaded file size using local storage option

* fix: Normalize attachment size from BIGINT before comparison

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 23:25:46 -04:00
Tom Moor 347bdb10d4 fix: Ensure OTP is bound to workspace (#12096)
* fix: Ensure OTP is bound to teamId

* fix: Address review feedback on OTP tenant scoping

- Trim whitespace in VerificationCode Redis keys to match DB lookup
  normalization.
- Redirect with invalid-code (rather than leaking a backend error)
  when no user exists for the email in the resolved team.
- Correct retrieve() JSDoc to state undefined instead of null.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 23:22:58 -04:00
Tom Moor e49e3136b6 Increase MCP guidance limit (#12097)
* Increase MCP guidance limit
Add new controls for Input

* PR feedback
2026-04-17 23:22:50 -04:00
Tom Moor 60903fef84 Allow passing CSP nonce to exported html (#12088)
* Allow passing CSP nonce to exported html

* test: Add nonce regression test, drop options from tags

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:00:34 -04:00
Tom Moor cbb53285a7 fix: Flaky test (#12087)
* fix: Flaky test

* fix: Validation values incorrect
2026-04-16 21:40:08 -04:00
Tom Moor 5bbc240628 feat: Add diffs to share subscription notifications (#12084)
* Add diffs to share subscription notifications

* Update cache key

* fix
2026-04-16 21:17:56 -04:00
Tom Moor 400c0aa262 fix: Flaky test (#12069)
* fix: Flaky test

* fix: Restrict /auth/redirect to JWT authentication only

Non-JWT tokens (API keys, OAuth) could reach the redirect endpoint
and produce a confusing "Unable to decode token" error. Restrict the
auth middleware to APP type so they are rejected before the handler.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:35:26 -04:00
Tom Moor 5e1a5a208f fix: Flaky test (#12085) 2026-04-16 20:35:15 -04:00
Tom Moor 8e371ea263 Add argument to suppressEmail when inviting users through API (#12082)
* Add argument to suppressEmails wehn inviting users

* Skip InviteSent flag when suppressEmail is set

Keeps the resend-invite counter accurate so users.resendInvite can
still deliver the first email when the initial invite was silent.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 20:26:12 -04:00
Tom Moor fccc343cb9 feat: Add Hebrew as a language option (#12083)
* feat: Add Hebrew as a language option

* Apply suggestions from code review

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 18:37:41 -04:00
Tom Moor 26f5bb9784 fix: Unable to search drafts without a collection (#12079)
* fix: Unable to search drafts without a collection

* PR feedback
2026-04-16 17:37:25 -04:00
dependabot[bot] 1596e51fa5 chore(deps): bump @node-oauth/oauth2-server from 5.2.1 to 5.3.0 (#12081)
Bumps [@node-oauth/oauth2-server](https://github.com/node-oauth/node-oauth2-server) from 5.2.1 to 5.3.0.
- [Release notes](https://github.com/node-oauth/node-oauth2-server/releases)
- [Changelog](https://github.com/node-oauth/node-oauth2-server/blob/master/CHANGELOG.md)
- [Commits](https://github.com/node-oauth/node-oauth2-server/compare/v5.2.1...v5.3.0)

---
updated-dependencies:
- dependency-name: "@node-oauth/oauth2-server"
  dependency-version: 5.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 17:36:49 -04:00
Translate-O-Tron a0acf410c5 New Crowdin updates (#11759)
* fix: New Thai translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Hebrew translations from Crowdin [ci skip]
2026-04-16 12:40:51 -04:00
Tom Moor 7a4b545e7f chore: vendor autotrack library (#12070)
The autotrack npm package is no longer maintained. Vendor the three
plugins we use (eventTracker, outboundLinkTracker, urlChangeTracker)
and their dom-utils dependencies into a single local JS file.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 07:42:21 -04:00
Tom Moor 15bd969cfa fix: Handle trailing space on code challenge method (#12068)
* fix: Handle trailing space on code challenge method

* Add tests for codeChallengeMethod whitespace trimming

Addresses review feedback: adds test coverage for the trim behavior
in saveAuthorizationCode, verifying trailing whitespace is stripped
and whitespace-only input is treated as absent.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 22:02:32 -04:00
Tom Moor 5e8901652e fix: 204 response for internal docs not found (#12067) 2026-04-15 21:55:16 -04:00
dependabot[bot] 395da9ea8d chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 (#12066)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 21:44:14 -04:00
Tom Moor 30a14d7022 PR feedback (#12064) 2026-04-15 21:26:43 -04:00
dependabot[bot] d7cea83ed7 chore(deps): bump express-rate-limit from 8.2.1 to 8.3.2 (#12058)
Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.2.1 to 8.3.2.
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.2.1...v8.3.2)

---
updated-dependencies:
- dependency-name: express-rate-limit
  dependency-version: 8.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 21:26:21 -04:00
dependabot[bot] a5219763d3 chore(deps): bump hono from 4.11.9 to 4.12.12 (#12059)
Bumps [hono](https://github.com/honojs/hono) from 4.11.9 to 4.12.12.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.11.9...v4.12.12)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 21:26:06 -04:00
dependabot[bot] 3c6e7ef042 chore(deps): bump dompurify from 3.3.3 to 3.4.0 (#12065)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.3 to 3.4.0.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.3...3.4.0)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 21:25:52 -04:00
Tom Moor f1033f37b8 chore: Patch upgrade all dependencies (#12061)
* chore: Patch upgrade all dependencies and fix type issues

Upgrades 38 packages to latest patch versions. Dedupes prosemirror-view
and @bull-board/api to fix type conflicts, pins @types/markdown-it to
14.1.1 via resolutions (14.1.2 has a breaking type change), and removes
an unused @ts-expect-error in mark.ts.

Also fixes npmMinimalAgeGate from 86400 to 1440 — the unit is minutes
not seconds, so it was blocking any package published in the last 60
days instead of 24 hours.

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

* fix: Update resolutions to match bumped dependency versions

Syncs @types/react (17.0.75 → 17.0.91), @hocuspocus/server (1.1.2 →
1.1.3), and prosemirror-transform (1.10.0 → 1.10.5) in the resolutions
field to match the upgraded versions in dependencies/devDependencies.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:18:55 -04:00
Tom Moor 36ab06ab3f feat: Add recent documents menu on desktop (#12063)
* feat: Add recent documents menu on desktop

* PR feedback
2026-04-15 21:12:57 -04:00
Tom Moor 03c3be4cf2 fix: npmMinimalAgeGate incorrectly set, upgrade axios, aws (#12057) 2026-04-15 08:45:55 -04:00
Tom Moor 2a2774a6d0 chore: Update modelcontextprotocol (#12052)
* chore: Update modelcontextprotocol

* fix: Restore native Web API classes after jest-fetch-mock setup

jest-fetch-mock replaces globalThis.Response with a cross-fetch polyfill
that doesn't support Web Streams (ReadableStream bodies become Buffers).
The MCP SDK's @hono/node-server adapter calls response.body.getReader()
which fails with the polyfilled Response. Since dontMock() is already
called, preserving the native classes is the correct behavior.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 08:15:43 -04:00
Tom Moor ff34c933eb fix: Remove dupe translation string 2026-04-15 08:12:32 -04:00
Tom Moor 0d98754f5f fix: Draft border is not visible in dark mode (#12051)
Add draft badge to header
2026-04-15 08:05:19 -04:00
Tom Moor ff2e408c05 fix: Search input in keyboard shortcuts is not rounded (#12047) 2026-04-15 08:04:53 -04:00
Tom Moor 6c569f3088 fix: Add default value for collaboratorIds (#12048) 2026-04-15 08:04:42 -04:00
Tom Moor b494f64c4e fix: Silence expected ResourceLockedError during Redlock retries (#12049)
ResourceLockedError is emitted on every retry attempt during lock
contention but was not handled, causing it to be logged as an
unexpected error and reported to Sentry (OUTLINE-CLOUD-CAW).

Fixes OUTLINE-CLOUD-CAW

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 08:04:31 -04:00
Tom Moor 89fe4b88be fix: User errors still reported to DD (#12050) 2026-04-15 08:04:23 -04:00
Tom Moor 3fa5e745be chore: Bump fast-xml-parser from 5.2.5 to 5.5.7 (#12054)
Adds a yarn resolution to upgrade the transitive dependency
fast-xml-parser to 5.5.7, resolving a security vulnerability.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 08:04:03 -04:00
Tom Moor f1e4077457 Update REA   U REAME.md (#12042) 2026-04-14 18:43:39 -04:00
Tom Moor 9b270dabde Add cmd+k shortcut hint to search input (#12045)
* Add cmd+k shortcut hint to search input

fix: Shrink on mobile

* fix shrinking, hide on mobile
2026-04-14 18:43:31 -04:00
Tom Moor d3f1884fa7 fix: Back/forward controls in desktop app (#12046)
* fix: Back/forward controls in desktop app

* PR feedback
2026-04-14 18:43:26 -04:00
Tom Moor 46f1f99ce6 fix: Code blocks should not appear collapsed in print/PDF export (#12038) 2026-04-14 08:53:00 -04:00
Tom Moor 88ae883bd1 chore: Simplify deleted team handling in teamProvisioner (#12036) 2026-04-13 22:47:24 -04:00
Tom Moor 831c6f0898 fix: User errors should not be set DD spans (#12035)
* fix: User errors should not be set DD spans

* refactor
2026-04-13 22:18:05 -04:00
Tom Moor b3042540c4 fix: Shared doc should respect 'Show last modified' option when logged in (#12032) 2026-04-13 21:39:09 -04:00
Tom Moor ff57958ebf fix: Cannot access property pos (#12031)
* fix: Cannot access property pos

* PR feedback, extend fix to cols
2026-04-13 21:10:29 -04:00
Tom Moor 0d47c10efc fix: Runtime check for indexeddb (#12028)
* fix: Runtime check for indexeddb access

* PR feedback
2026-04-13 20:29:54 -04:00
Tom Moor db26dd5020 fix: Did not find anchor as previous sibling of heading (#12029) 2026-04-13 20:29:45 -04:00
Tom Moor 6c7a38f755 fix: Handle unhandled thrown object (#12011)
* fix: Handle unhandled thrown object

* fix: Improve unauthorized socket error handling

Type the error as unknown since socket.io sends deserialized JSON,
use String() coercion for safe message extraction, and attach the
original payload as Sentry extra context for debugging.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:20:19 -04:00
Tom Moor f4f2506d36 fix: Guard IndexeddbPersistence for environments without indexedDB (#12027)
* fix: Guard IndexeddbPersistence for environments without indexedDB

In environments where `indexedDB` is unavailable (e.g. certain mobile
browsers or privacy-restricted contexts), y-indexeddb throws a
ReferenceError. This guards the creation with a typeof check and skips
local persistence gracefully, falling back to remote-only sync.

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

* fix: Track local persistence availability separately from sync state

Address review feedback: instead of forcing isLocalSynced=true when
indexedDB is unavailable (which drops the cached read-only render),
track hasLocalPersistence separately and derive readiness as
(!hasLocalPersistence || isLocalSynced) for showCache and onSynced.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:18:09 -04:00
Tom Moor 51ba02715f chore: Add missing error handler on MutexLock (#12021)
* chore: Add missing error handler on MutexLock

* PR feedback
2026-04-13 18:12:21 -04:00
Tom Moor e61de60475 fix: Handline for top-level node in getCurrentBlock (#12022) 2026-04-13 18:11:41 -04:00
Tom Moor d9b54c63c0 fix: Guard against undefined boundsRef in MediaDimension (#12026)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:05:20 -04:00
Tom Moor fc16d3100a fix: Auto-hide TOC when window shrinks below mobile breakpoint (#12014) 2026-04-13 18:04:27 -04:00
Tom Moor 054404d716 fix: Missing + on shared doc shortcut display (#12013)
* fix: Missing + on shared doc shortcut display

* fix: Show "+" between shortcut keys on Windows

Add shared `shortcutSeparator` constant and use it across all shortcut
renderers so Windows displays "Ctrl+K" instead of "CtrlK".

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:04:18 -04:00
Tom Moor aab64da0e9 fix: Natural embed resizing (#12012)
* fix: Natural embed resizing, closes #11924

* fix: Make embed height snap and min height configurable props

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:04:12 -04:00
Tom Moor f9c5540582 fix: action.ancestors can be undefined (#12024) 2026-04-13 18:04:04 -04:00
Tom Moor 4a1c9dedff chore: Remove url from error message to improve fingerprint matching (#12023) 2026-04-13 18:03:59 -04:00
dependabot[bot] 336bbb251f chore(deps): bump the fortawesome group with 3 updates (#12017)
Bumps the fortawesome group with 3 updates: [@fortawesome/fontawesome-svg-core](https://github.com/FortAwesome/Font-Awesome), [@fortawesome/free-brands-svg-icons](https://github.com/FortAwesome/Font-Awesome) and [@fortawesome/free-solid-svg-icons](https://github.com/FortAwesome/Font-Awesome).


Updates `@fortawesome/fontawesome-svg-core` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/FortAwesome/Font-Awesome/releases)
- [Changelog](https://github.com/FortAwesome/Font-Awesome/blob/7.x/CHANGELOG.md)
- [Commits](https://github.com/FortAwesome/Font-Awesome/compare/7.1.0...7.2.0)

Updates `@fortawesome/free-brands-svg-icons` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/FortAwesome/Font-Awesome/releases)
- [Changelog](https://github.com/FortAwesome/Font-Awesome/blob/7.x/CHANGELOG.md)
- [Commits](https://github.com/FortAwesome/Font-Awesome/compare/7.1.0...7.2.0)

Updates `@fortawesome/free-solid-svg-icons` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/FortAwesome/Font-Awesome/releases)
- [Changelog](https://github.com/FortAwesome/Font-Awesome/blob/7.x/CHANGELOG.md)
- [Commits](https://github.com/FortAwesome/Font-Awesome/compare/7.1.0...7.2.0)

---
updated-dependencies:
- dependency-name: "@fortawesome/fontawesome-svg-core"
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: fortawesome
- dependency-name: "@fortawesome/free-brands-svg-icons"
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: fortawesome
- dependency-name: "@fortawesome/free-solid-svg-icons"
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: fortawesome
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 17:11:51 -04:00
dependabot[bot] 299e0723f3 chore(deps): bump nodemailer from 7.0.11 to 7.0.13 (#12019)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 7.0.11 to 7.0.13.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v7.0.11...v7.0.13)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 7.0.13
  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-04-13 17:11:32 -04:00
dependabot[bot] b935dd7d27 chore(deps): bump prosemirror-tables from 1.8.3 to 1.8.5 (#12020)
Bumps [prosemirror-tables](https://github.com/ProseMirror/prosemirror-tables) from 1.8.3 to 1.8.5.
- [Release notes](https://github.com/ProseMirror/prosemirror-tables/releases)
- [Changelog](https://github.com/ProseMirror/prosemirror-tables/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ProseMirror/prosemirror-tables/compare/v1.8.3...v1.8.5)

---
updated-dependencies:
- dependency-name: prosemirror-tables
  dependency-version: 1.8.5
  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-04-13 17:11:14 -04:00
Tom Moor b4c1f88731 feat: Allow document unfurling with shareId (#12007)
* feat: Allow document unfurling with shareId

* fix: Handle collection shares, share-scoped URLs, and unauthenticated unfurls

- Return 204 instead of 404 for collection shares without a document
- Use share-scoped URL in unfurl response so hover previews stay within
  the share context
- Add test coverage for unauthenticated share URL unfurling

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

* perf: Only resolve team from context for non-UUID share identifiers

loadPublicShare only requires teamId when the share identifier is a
slug (urlId), not a UUID. Skip the getTeamFromContext DB lookup on the
common UUID path.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 21:46:36 -04:00
Tom Moor b650a0f9df fix: New shares do not include children (#12009) 2026-04-12 21:46:23 -04:00
Tom Moor 6874b02cc7 Add max character count to inputs (#12006) 2026-04-12 11:44:28 -04:00
Tom Moor 4d799e7690 fix: Checklist toggle overlapping content in table cells (#12005)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 08:54:54 -04:00
Tom Moor e8bafaa9f3 Refactor share policy guards (#12004) 2026-04-11 19:51:23 -04:00
Copilot d54a861894 Avoid reporting max payload size exceeded errors to Sentry in collaboration server (#12002)
Agent-Logs-Url: https://github.com/outline/outline/sessions/ec7f1b13-6d7e-49d7-a8a4-f1223ba07a93

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-04-11 19:29:14 -04:00
Tom Moor fb9f4bb991 feat: Allow replacing custom emoji image (#11998)
* feat: Allow replacing custom emoji image
2026-04-10 18:51:59 -04:00
Tom Moor c6a1db6bd1 fix: Flaky i18n test from repeated singleton re-initialization (#11999)
* fix: Flaky i18n test from repeated singleton re-initialization

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

* fix: Remove redundant initI18n call, rely on global test setup

initI18n() is already called in app/test/setup.ts for all app tests.
The extra call in beforeAll could re-introduce the same race condition.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:19:00 -04:00
Tom Moor 79df2f2dc8 fix: Dropped content in Markdown parser with mixed checklist content (#11994)
* fix: Dropped content in Markdown parser with mixed checklist content

* fix: Treat non-checkbox items as unchecked in mixed checkbox lists

When a bullet list contains a mix of checkbox and regular items, the
markdown-it checkbox rule converts the list to a checkbox_list but
leaves non-checkbox items as list_item tokens. Since the Prosemirror
schema requires checkbox_item+ children, these invalid list_item nodes
cause the entire list to be silently dropped — explaining the content
truncation reported in #11988.

Convert remaining list_item tokens that are direct children of a
checkbox_list into unchecked checkbox_item tokens. Uses a level stack
to avoid converting nested bullet/ordered list items.

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

* refactor: Move checkbox tests to collocated checkboxes.test.ts

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 08:07:28 -04:00
Tom Moor 15524cdd08 fix: Sanitize mention href (#11993)
* fix: Sanitize mention href

* Add llm instructions

* Potential fix for pull request finding
2026-04-09 21:07:14 -04:00
wmTJc9IK0Q 21d4816a00 Copy fullWidth property when duplicating documents (#11980)
* Add fullWidth property copying to document duplication

Agent-Logs-Url: https://github.com/wmTJc9IK0Q/outline/sessions/6f30db31-b386-4c3d-8f04-db4dacfc2cdc

Co-authored-by: wmTJc9IK0Q <171362836+wmTJc9IK0Q@users.noreply.github.com>

* Fix lint errors in tests

Agent-Logs-Url: https://github.com/wmTJc9IK0Q/outline/sessions/6f30db31-b386-4c3d-8f04-db4dacfc2cdc

Co-authored-by: wmTJc9IK0Q <171362836+wmTJc9IK0Q@users.noreply.github.com>

* Apply suggestions from code review

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

* Remove unnecessary declaration

---------

Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-09 20:58:04 -04:00
Tom Moor c0ebed66f5 feat: Add patch support to MCP (#11987) 2026-04-09 20:57:02 -04:00
dependabot[bot] d840a7abe7 chore(deps): bump axios from 1.13.2 to 1.13.5 (#11992)
Bumps [axios](https://github.com/axios/axios) from 1.13.2 to 1.13.5.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.13.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 20:45:13 -04:00
Tom Moor c72346b799 fix: Skip auto-closing PRs with "pinned" label (#11991)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 00:27:19 +00:00
Tom Moor fdb0d84e13 chore: Stagger cron cleanup tasks (#11986)
* chore: Stagger cron cleanup tasks

* PR feedback
2026-04-08 23:46:02 -04:00
Tom Moor e24fe02f9b fix: Timeout on query notice 2026-04-06 21:54:57 -04:00
Tom Moor 34126a55bf fix: Small issue where scrollable area borders do not appear on first render (#11979) 2026-04-06 21:06:12 -04:00
Tom Moor 3255f6b9ff fix: Minor fixes to query notices (#11978) 2026-04-06 19:54:10 -04:00
Tom Moor 64e75dac76 fix: Address various a11y findings (#11977)
* A11y improvements

* fix: Accessibility improvements for sidebar, layout, and emoji icons

- Add role="main" to content area and role="contentinfo" to right sidebar
- Add aria-expanded to sidebar Disclosure toggle button
- Add nav landmark with aria-label to shared sidebar navigation
- Render SidebarLink as button instead of div when no link target
- Hide decorative emoji icons from screen readers (aria-hidden)
- Add aria-hidden to EmojiIcon SVG element

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

* fix: Restore PopoverTrigger in FindAndReplace, add role to span

PopoverAnchor broke the find/replace popover. Revert to PopoverTrigger
and instead add role="button" and aria-label to the span so ARIA
attributes from Radix are valid on the element.

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

* fix: Sidebar button styling

* fix: Use semantic list elements for References document list

Change the References list container from div to ul and wrap each
ReferenceListItem in an li element for proper screen reader semantics.

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

* fix: Address PR review feedback for accessibility changes

- Heading buttons: switch from mousedown to click for keyboard access
- Heading fold: add aria-expanded attribute
- FindAndReplace: use real button element instead of span with role
- SidebarLink: branch render to avoid passing NavLink props to button
- Right sidebar: use role=complementary instead of contentinfo

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

* fix: Use translation hook for FindAndReplace, revert anchor click handler

- Use t() for aria-label in FindAndReplace button
- Revert heading anchor from click back to mousedown to avoid side effects

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

* fix: Add ts-expect-error for styled NavLink overload mismatch

The spread props on the NavLink branch cause a TypeScript overload
mismatch that was previously suppressed. Re-add the suppression.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 18:59:53 -04:00
Tom Moor ffe4e5c7e4 fix: Remove forced newline from toggle in list (#11976)
* Use helper

* Address review feedback: add comment for offset, rename plugin variable
2026-04-06 18:54:44 -04:00
Tom Moor 3ace24c966 chore: Add attachment permissions comment (#11972) 2026-04-05 18:36:43 -04:00
Tom Moor f8de6f24bf Merge branch 'main' of github.com:outline/outline 2026-04-05 18:15:05 -04:00
Tom Moor 09fe5d6785 feat: Auto-collapse tall code blocks (#11967)
* Styling finetuning

* test

* Refactor collapsible code blocks: line-based collapse, styling fixes, use EditorStyleHelper

- Use line count (12+) instead of DOM height measurement for collapse threshold
- Handle missing dictionary gracefully for server-side rendering
- Add addToHistory:false to toggleCodeBlockCollapse command
- Move .code-block class name to EditorStyleHelper
- Use neutral button variant for collapse toggle, show on hover/focus
- Fix fade overlay border-radius and inset, clip line numbers via clip-path
- Remove suppressAutoExpand hack; preventDefault already handles it
2026-04-05 17:54:54 -04:00
Tom Moor 6b950aae81 Refactor collapsible code blocks: line-based collapse, styling fixes, use EditorStyleHelper
- Use line count (12+) instead of DOM height measurement for collapse threshold
- Handle missing dictionary gracefully for server-side rendering
- Add addToHistory:false to toggleCodeBlockCollapse command
- Move .code-block class name to EditorStyleHelper
- Use neutral button variant for collapse toggle, show on hover/focus
- Fix fade overlay border-radius and inset, clip line numbers via clip-path
- Remove suppressAutoExpand hack; preventDefault already handles it

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 16:33:04 -04:00
Tom Moor 55e29bb82e test 2026-04-05 16:30:39 -04:00
Tom Moor 45b6c3eefa Styling finetuning 2026-04-05 09:43:31 -04:00
Tom Moor 121c6e198a wip 2026-04-05 09:23:24 -04:00
Tom Moor 5a8e730d81 wip 2026-04-05 08:11:27 -04:00
Tom Moor 2fffb2f83d wip 2026-04-04 21:29:44 -04:00
Tom Moor 30d00df1e3 fix: Sidebar auto-opens when draft comment is present (#11964) 2026-04-04 17:50:49 -04:00
Tom Moor d4dec42bc5 fix: Validate host parameter stored in OAuth state on failure redirect (#11956)
* fix: Validate host parameter stored in OAuth state on auth failure path

* fix: Validate OAuth state host to prevent open redirect

Sanitize the host parameter from OAuth state before using it in error
redirects. Adds userinfo stripping to parseDomain's normalizeUrl to
prevent bypasses like "subdomain.base@evil.com", validates custom
domains against registered teams, and introduces Team.findByDomain
with input normalization for consistent domain lookups.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 16:13:54 -04:00
Tom Moor a411e08f1f chore: Address code quality findings (#11960)
* chore: Address code quality findings

* Round 2, quality findings

* fix: Add fallback for MediaQueryList.addEventListener in test env

The jsdom test environment doesn't implement addEventListener on
MediaQueryList. Prefer addEventListener but fall back to the
deprecated addListener when unavailable.
2026-04-04 16:11:10 -04:00
Tom Moor a0c70cee62 fix: Email is removed from group members table (#11961) 2026-04-04 15:58:05 -04:00
Tom Moor e0021a3d4f Display keyboard shortcuts in menus where available (#11959)
* Display keyboard shortcuts in menus where available

* feat: Display keyboard shortcuts in action menus

Pass shortcut data from Action definitions through to menu items and
render formatted key symbols on the right side of menu entries. Handles
platform differences via normalizeKeyDisplay. Also adds Control key
display support and uppercase formatting for single-letter shortcuts.


Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 15:19:11 -04:00
Tom Moor a02793514c fix: Hide image controls and pointers in present mode (#11958) 2026-04-04 12:31:56 -04:00
Tom Moor 741f6c07d2 fix: Current user last active at (#11957)
* fix: Last active timestamp should always read as now for current user

* Shorten language on members table
2026-04-04 12:28:16 -04:00
Tom Moor b9c9dc4127 Potential fixes for 3 code quality findings (#11955)
* Apply suggested fix to shared/editor/plugins/SuggestionsMenuPlugin.ts from Copilot Autofix

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* Apply suggested fix to shared/editor/plugins/SuggestionsMenuPlugin.ts from Copilot Autofix

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* Apply suggested fix to shared/editor/plugins/SuggestionsMenuPlugin.ts from Copilot Autofix

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-04 11:07:40 -04:00
Tom Moor 4ad1baa115 fix: Add support for full width at symbol (#11951) 2026-04-04 09:05:20 -04:00
Tom Moor f901435226 fix: Cannot navigate to document out of present mode (#11952) 2026-04-04 09:05:16 -04:00
Seungwoo Ham 81ef635f36 fix: Update mention search query during IME compositionupdate (#11944) 2026-04-03 22:17:30 -04:00
Tom Moor d4f747b43d chore: Remove auto creation of share link (#11950)
* Remove share pre-creation

* Disable toggle while saving

* cleanup unused methods
2026-04-03 21:09:34 -04:00
Tom Moor 3421b5a8b5 fix: Breadcrumb padding 2026-04-03 19:50:50 -04:00
Tom Moor b7afc9ec68 fix: Notification panel height (#11949) 2026-04-03 23:39:31 +00:00
Tom Moor d2f94f54ed fix: Short search filter on Group settings (#11945) 2026-04-03 18:49:48 -04:00
Tom Moor c4930f315c fix: Breadcrumb item text disappearing when document has icon (#11942) (#11948)
Move document icons out of the ContextMenu trigger span and into the
action's icon property, consistent with how collection icons work. Block-level
icon elements (FontAwesome, custom emoji) inside the inline span were taking
a full line and pushing text below the overflow clip. Also remove display:flex
from breadcrumb Item so text-overflow:ellipsis works correctly.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:49:36 -04:00
Apoorv Mishra 5d5213101e Move document redirect logic one component up (#11917)
* fix: move redirect logic one component up

* fix: use <Redirect>
2026-04-02 20:17:40 -04:00
Tom Moor 025f422695 chore: Disable public document subscription when SMTP is not configured (#11938) 2026-04-02 20:16:58 -04:00
Tom Moor b2aad71cb4 chore: Move welcome email to processor (#11939)
* chore: Move welcome email to processor

* fix: Restore welcome email on invite acceptance
2026-04-02 20:16:47 -04:00
Tom Moor 12c71f267e Improve scoping of public share subscriptions (#11932)
* Improve scoping of public share subscriptions

* fix: Add missing transaction, includeChildDocuments check, and test documentId

- Pass { transaction } to ShareSubscription.create in the subscribe handler
  so the insert runs atomically with the duplicate-check findOne/lock
- Skip ancestor-scoped subscription notifications when the share has
  includeChildDocuments=false, preventing notifications for inaccessible docs
- Add required documentId field to all share subscription tests

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

* fix: Resolve type error for nullable share.documentId in tests

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

* JSDoc

* Hide subscription option for logged-in users

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 08:08:05 -04:00
Tom Moor 9516459d31 fix: Cannot subscribe in prod (#11931) 2026-04-01 22:57:12 -04:00
Tom Moor b3227050c8 fix: Button alignment in find and replace 2026-04-01 22:23:23 -04:00
Tom Moor bcc5a94070 feat: Add email subscriptions to public docs (#11911)
* feat: Add email subscriptions to public docs
2026-04-01 21:56:50 -04:00
Tom Moor b354d1f5b0 Use CSS highlights instead of editor decorations when available (#11929)
* Use CSS highlights instead of editor decorations when available

* Fix scroll target for non-HTML elements and refresh highlights on toggle fold

- Use `Element` instead of `HTMLElement` for scroll target so SVG/MathML
  elements are handled correctly
- Bump highlight generation on toggle fold/unfold transactions so
  newly visible matches get proper highlight ranges
- Cache decoration getter result to avoid redundant mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 21:40:57 -04:00
1072 changed files with 62936 additions and 27344 deletions
+12 -1
View File
@@ -24,7 +24,18 @@
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
}
],
"tsconfig-paths-module-resolver"
[
"module-resolver",
{
"root": ["./"],
"alias": {
"@server": "./server",
"@shared": "./shared",
"~": "./app",
"plugins": "./plugins"
}
}
]
],
"env": {
"production": {
+11
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 ––––––––––
@@ -218,6 +223,12 @@ RATE_LIMITER_ENABLED=true
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# Multiplier applied to the hardcoded per-endpoint API rate limits. Use values
# greater than 1 to make the limits more lenient (e.g. 2 doubles the allowed
# requests), or less than 1 to make them stricter. Effective limits are rounded
# to the nearest integer with a minimum of 1. Defaults to 1.
RATE_LIMITER_MULTIPLIER=1
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– INTEGRATIONS –––––––––––
+11
View File
@@ -17,6 +17,8 @@ SLACK_VERIFICATION_TOKEN=test-token-123
GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
GITHUB_APP_NAME=outline-test;
GITHUB_APP_ID=123
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----"
GITLAB_CLIENT_ID=123
GITLAB_CLIENT_SECRET=123
@@ -29,6 +31,15 @@ OIDC_USERINFO_URI=http://localhost/userinfo
IFRAMELY_API_KEY=123
NOTION_CLIENT_ID=123
NOTION_CLIENT_SECRET=123
LINEAR_CLIENT_ID=123
LINEAR_CLIENT_SECRET=123
FIGMA_CLIENT_ID=123
FIGMA_CLIENT_SECRET=123
RATE_LIMITER_ENABLED=false
FILE_STORAGE=local
+16
View File
@@ -0,0 +1,16 @@
name: Install
description: Set up Node.js, Corepack, and install dependencies with yarn cache
runs:
using: composite
steps:
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Use Node.js 24.x
uses: actions/setup-node@v5
with:
node-version: 24.x
cache: "yarn"
- name: Install dependencies
shell: bash
run: yarn install --immutable
+6
View File
@@ -31,6 +31,12 @@ jobs:
if (prAge < TWO_WEEKS) continue;
const hasSkipLabel = pr.labels.some(label =>
label.name === 'pinned'
);
if (hasSkipLabel) continue;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -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
+38 -98
View File
@@ -18,78 +18,23 @@ env:
SMTP_USERNAME: localhost
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- name: Use Node.js 24.x
uses: actions/setup-node@v5
with:
node-version: 24.x
cache: "yarn"
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn install --immutable
lint:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- run: yarn lint --quiet
types:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- run: yarn tsc
changes:
runs-on: ubuntu-latest
outputs:
config: ${{ steps.filter.outputs.config }}
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
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: |
config:
- '.github/**'
- 'vite.config.ts'
- 'vitest.config.ts'
server:
- 'server/**'
- 'shared/**'
@@ -100,9 +45,36 @@ jobs:
- 'shared/**'
- 'package.json'
- 'yarn.lock'
deps:
- 'package.json'
- 'yarn.lock'
- '.yarnrc.yml'
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- run: yarn lint --quiet
types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- run: yarn tsc
audit:
needs: changes
if: ${{ needs.changes.outputs.deps == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- run: yarn npm audit --severity high --recursive --environment production
test:
needs: [setup, changes]
needs: changes
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -110,21 +82,11 @@ jobs:
test-group: [app, shared]
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- uses: ./.github/actions/install
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [setup, changes]
needs: changes
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
services:
@@ -148,45 +110,23 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- uses: ./.github/actions/install
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | awk "NR % 4 == (${{ matrix.shard }} - 1)")
yarn test --maxWorkers=2 $TESTFILES
run: yarn test:server --maxWorkers=2 --shard=${{ matrix.shard }}/4
bundle-size:
needs: [setup, types, changes]
needs: changes
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- uses: ./.github/actions/install
- name: Set environment to production
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
+16 -22
View File
@@ -1,9 +1,10 @@
name: Docker
name: Publish build
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,10 +162,11 @@ jobs:
retention-days: 1
merge:
runs-on: ubicloud-standard-8
runs-on: blacksmith-8vcpu-ubuntu-2404
needs:
- build-amd
- build-arm
environment: dockerhub
steps:
- name: Download digests
uses: actions/download-artifact@v4
@@ -187,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 }}"
+1
View File
@@ -14,6 +14,7 @@ data/*
*.pem
*.key
*.cert
.history
# Yarn Berry
.yarn/*
-63
View File
@@ -1,63 +0,0 @@
{
"workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
"projects": [
{
"displayName": "server",
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/setupMocks.js"
],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
},
{
"displayName": "app",
"roots": ["<rootDir>/app"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
},
{
"displayName": "shared-node",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
}
]
}
+26 -2
View File
@@ -31,7 +31,7 @@
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-explicit-any": "warn",
"no-explicit-any": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
@@ -73,9 +73,30 @@
"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"],
"no-useless-escape": "off",
"react/react-in-jsx-scope": "off",
"typescript/await-thenable": "error",
"typescript/no-duplicate-type-constituents": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/require-array-sort-compare": "error",
"react/self-closing-comp": [
"error",
{
@@ -87,6 +108,9 @@
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"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",
{
+9 -1
View File
@@ -1,6 +1,14 @@
nodeLinker: node-modules
npmMinimalAgeGate: 86400
enableScripts: false
npmMinimalAgeGate: 4320
npmPreapprovedPackages:
- outline-icons
# Build-time advisories that don't affect runtime request handling.
# Re-evaluate when bumping the relevant dev/build dep.
npmAuditIgnoreAdvisories:
- "1113517" # GHSA-mw96-cpmx-2vgc rollup <2.80.0 path traversal (workbox-build, build-time)
- "1113686" # GHSA-5c6j-r48x-rmvq serialize-javascript RCE (@rollup/plugin-terser, build-time)
+15 -2
View File
@@ -9,7 +9,7 @@ There is a web client which is fully responsive and works on mobile devices.
- **`shared/`** - Shared TypeScript types, utilities, and editor components
- **`plugins/`** - Plugin system for extending functionality
- **`public/`** - Static assets served directly
- **Various config files** - TypeScript, Vite, Jest, Prettier, Oxlint configurations
- **Various config files** - TypeScript, Vite, Vitest, Prettier, Oxlint configurations
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
@@ -46,6 +46,18 @@ You're an expert in the following areas:
yarn install
```
- When adding a `resolutions` entry to address a security advisory in a transitive dependency, target only the specific vulnerable descriptors using the `name@npm:<range>` syntax rather than overriding the package globally. Inspect `yarn.lock` to find the exact ranges requested by upstream packages and add one entry per vulnerable range, e.g.:
```json
"resolutions": {
"qs@npm:^6.5.2": "^6.14.2",
"qs@npm:^6.11.0": "^6.14.2",
"qs@npm:^6.14.0": "^6.14.2"
}
```
This keeps overrides scoped to the affected dependents and avoids forcing unrelated consumers onto an incompatible version.
## TypeScript Usage
- Use strict mode.
@@ -140,7 +152,7 @@ yarn sequelize migration:create --name=add-field-to-table
## Testing
- Run tests with Jest:
- Run tests with Vitest:
```bash
# Run a specific test file (preferred)
@@ -188,6 +200,7 @@ yarn test:shared # All shared code tests
## Security
- Sanitize all user input.
- Always use `sanitizeUrl()` when setting `href` or `src` from user-controlled data in ProseMirror `toDOM` methods, regardless of whether it is imported via an alias or a relative path. Unlike React components, `toDOM` writes raw DOM and does not sanitize attribute values.
- Use CSRF protection.
- Use rateLimiter middleware for sensitive endpoints.
- Follow OWASP guidelines.
+1 -1
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:24.14.1-slim AS runner
FROM node:24.16.0-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:24.14.1 AS deps
FROM node:24.16.0 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.6.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-03-18
Change Date: 2030-06-06
Change License: Apache License, Version 2.0
+11 -11
View File
@@ -27,23 +27,23 @@ Please see the [documentation](https://docs.getoutline.com/s/hosting/) for runni
If you have questions or improvements for the docs please create a thread in [GitHub discussions](https://github.com/outline/outline/discussions).
# Development
# Contributing
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
> **Note:** Please do not submit AI-generated pull requests. We receive a high volume of mass, low-quality PRs generated by AI tools like Claude, ChatGPT, and Copilot from contributors who are unfamiliar with the codebase. These PRs are almost never mergeable and waste maintainer time reviewing them. If youd like to contribute, please take the time to understand the codebase and write your changes thoughtfully.
## Contributing
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) wed also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
Outline is built and maintained by a small team your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
If youre looking for ways to get started, heres a list of ways to help us improve Outline:
- [Translation](docs/TRANSLATION.md) into other languages
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
- Performance improvements, both on server and frontend
- Developer happiness and documentation
- Bugs and other issues listed on GitHub
- Bugs, quality fixes, and other issues listed on GitHub
# Development
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
## Architecture
@@ -61,7 +61,7 @@ can be enabled for all categories by setting `DEBUG=*` or for specific categorie
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
To add new tests, write your tests with [Vitest](https://vitest.dev/) and add a file with `.test.ts` extension next to the tested code.
```shell
# To run all tests
@@ -72,7 +72,7 @@ make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly with jest:
frontend and backend tests directly with vitest:
```shell
# To run backend tests
+1
View File
@@ -1,5 +1,6 @@
{
"extends": ["../.oxlintrc.json"],
"ignorePatterns": ["**/*.d.ts"],
"plugins": ["oxc", "eslint", "typescript", "react"],
"overrides": [
{
+20 -1
View File
@@ -1,5 +1,8 @@
import { PlusIcon, TrashIcon } from "outline-icons";
import copy from "copy-to-clipboard";
import { CopyIcon, PlusIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import stores from "~/stores";
import env from "~/env";
import type ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
@@ -25,6 +28,22 @@ export const createApiKey = createAction({
},
});
export const copyApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
name: ({ t }) => t("Copy"),
analyticsName: "Copy API key",
section: SettingsSection,
icon: <CopyIcon />,
visible: () => !!apiKey.value,
perform: ({ t }) => {
copy(apiKey.value, {
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
toast.success(t("API key copied"));
},
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
name: ({ t, isMenu }) =>
+109 -26
View File
@@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import invariant from "invariant";
import uniqBy from "lodash/uniqBy";
import { capitalize, uniqBy } from "es-toolkit/compat";
import {
DownloadIcon,
DuplicateIcon,
@@ -39,11 +39,13 @@ import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { ExportContentType, TeamPreference } from "@shared/types";
import { isMobile } from "@shared/utils/browser";
import { getEventFiles } from "@shared/utils/files";
import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import { client } from "~/utils/ApiClient";
import DocumentDelete from "~/scenes/DocumentDelete";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
@@ -79,7 +81,7 @@ import {
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type {
Action,
@@ -107,19 +109,21 @@ export const openDocument = createActionWithChildren({
shortcut: ["o", "d"],
keywords: "go to",
icon: <DocumentIcon />,
children: ({ stores }) => {
children: ({ stores, t }) => {
const nodes = stores.collections.navigationNodes.reduce(
(acc, node) => [...acc, ...node.children],
[] as NavigationNode[]
);
const documents = stores.documents.orderedData;
return uniqBy([...documents, ...nodes], "id").map((item) =>
createInternalLinkAction({
return uniqBy([...documents, ...nodes], "id").map((item) => {
const document = stores.documents.get(item.id);
return createInternalLinkAction({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
name: item.title,
description: document ? documentBreadcrumbText(document, t) : undefined,
icon: item.icon ? (
<Icon
value={item.icon}
@@ -127,12 +131,12 @@ export const openDocument = createActionWithChildren({
color={item.color ?? undefined}
/>
) : (
<DocumentIcon />
<DocumentIcon outline={item.isDraft} />
),
section: DocumentSection,
to: item.url,
})
);
});
});
},
});
@@ -236,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",
@@ -268,10 +292,14 @@ const createDocumentBefore = createInternalLinkAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
if (!document?.collectionId) {
return false;
}
const collection = stores.collections.get(document.collectionId);
if (collection?.sort.field === "title") {
return false;
}
return canCreateSiblingDocument(stores, document);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
@@ -306,10 +334,14 @@ const createDocumentAfter = createInternalLinkAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
if (!document?.collectionId) {
return false;
}
const collection = stores.collections.get(document.collectionId);
if (collection?.sort.field === "title") {
return false;
}
return canCreateSiblingDocument(stores, document);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
@@ -334,6 +366,18 @@ const createDocumentAfter = createInternalLinkAction({
},
});
function isAlphabeticallySorted(
stores: ActionContext["stores"],
activeDocumentId: string
): boolean {
const document = stores.documents.get(activeDocumentId);
if (!document?.collectionId) {
return false;
}
const collection = stores.collections.get(document.collectionId);
return collection?.sort.field === "title";
}
export const createNewDocument = createActionWithChildren({
name: ({ t }) => t("New document"),
analyticsName: "New document",
@@ -341,17 +385,48 @@ export const createNewDocument = createActionWithChildren({
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId) {
if (!activeDocumentId || !currentTeamId) {
return false;
}
return (
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
if (!stores.policies.abilities(currentTeamId).createDocument) {
return false;
}
return !isAlphabeticallySorted(stores, activeDocumentId);
},
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
export const createNewDocumentInAlphabeticalCollection =
createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId || !currentTeamId) {
return false;
}
if (!stores.policies.abilities(currentTeamId).createDocument) {
return false;
}
if (!stores.policies.abilities(activeDocumentId).createChildDocument) {
return false;
}
return isAlphabeticallySorted(stores, activeDocumentId);
},
to: ({ activeDocumentId, sidebarContext }) => {
const [pathname, search] =
newNestedDocumentPath(activeDocumentId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
@@ -737,8 +812,6 @@ export const copyDocumentAsPlainText = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const { ProsemirrorHelper } =
await import("~/models/helpers/ProsemirrorHelper");
copy(ProsemirrorHelper.toPlainText(document));
toast.success(t("Text copied to clipboard"));
}
@@ -970,7 +1043,11 @@ export const openDocumentInDesktop = createAction({
}
const document = stores.documents.get(activeDocumentId);
return (
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
isCloudHosted &&
(isMac || isWindows) &&
!!document &&
!document.isDeleted &&
!isMobile()
);
},
perform: ({ activeDocumentId, stores }) => {
@@ -988,9 +1065,14 @@ export const presentDocument = createAction({
analyticsName: "Present document",
section: ActiveDocumentSection,
icon: <EmbedIcon />,
shortcut: ["Meta+Alt+p"],
visible: ({ activeDocumentId }) => !!activeDocumentId,
shortcut: ["Control+Alt+KeyP"],
visible: ({ activeDocumentId }) => !!activeDocumentId && !isMobile(),
perform: ({ activeDocumentId, stores }) => {
if (stores.ui.presentationData) {
stores.ui.setPresentingDocument(null);
return;
}
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
@@ -1520,6 +1602,7 @@ export const rootDocumentActions = [
createDocument,
createDraftDocument,
createNewDocument,
createNewDocumentInAlphabeticalCollection,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
+1 -1
View File
@@ -2,7 +2,7 @@ import { PlusIcon } from "outline-icons";
import { createAction } from "~/actions";
import { TeamSection } from "../sections";
import stores from "~/stores";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
export const createEmoji = createAction({
name: ({ t }) => `${t("New emoji")}`,
+4 -1
View File
@@ -241,7 +241,10 @@ export const logout = createAction({
section: NavigationSection,
icon: <LogoutIcon />,
perform: async () => {
await stores.auth.logout({ userInitiated: true });
await stores.auth.logout({
userInitiated: true,
clearCache: true,
});
},
});
+6 -2
View File
@@ -3,6 +3,7 @@ import { LinkIcon, RestoreIcon, TrashIcon, DownloadIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import { ExportContentType } from "@shared/types";
import Revision from "~/models/Revision";
import stores from "~/stores";
import { createAction, createActionWithChildren } from "~/actions";
import { RevisionSection } from "~/actions/sections";
@@ -21,7 +22,7 @@ export const restoreRevision = createAction({
section: RevisionSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId }) => {
perform: async ({ event, location, activeDocumentId, getActiveModel }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
@@ -30,7 +31,10 @@ export const restoreRevision = createAction({
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const revisionId = getActiveModel(Revision)?.id ?? match?.params.revisionId;
if (!revisionId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
+1 -2
View File
@@ -25,6 +25,7 @@ import {
settingsPath,
urlify,
} from "~/utils/routeHelpers";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
import { ActiveTemplateSection, TemplateSection } from "../sections";
import Template from "~/models/Template";
import { AvatarSize } from "~/components/Avatar";
@@ -200,8 +201,6 @@ export const copyTemplateAsPlainText = createAction({
perform: async ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
const { ProsemirrorHelper } =
await import("~/models/helpers/ProsemirrorHelper");
copy(ProsemirrorHelper.toPlainText(template));
toast.success(t("Text copied to clipboard"));
}
+9 -2
View File
@@ -17,8 +17,12 @@ import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
import type { Action as KbarAction } from "kbar";
export function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
export function resolve<T>(value: unknown, context: ActionContext): T {
return (
typeof value === "function"
? (value as (context: ActionContext) => T)(context)
: value
) as T;
}
export const ActionSeparator: TActionSeparator = {
@@ -132,6 +136,7 @@ export function actionToMenuItem(
tooltip: resolve<React.ReactChild>(action.tooltip, context),
selected: resolve<boolean>(action.selected, context),
dangerous: action.dangerous,
shortcut: action.shortcut,
onClick: () => performAction(action, context),
};
@@ -143,6 +148,7 @@ export function actionToMenuItem(
icon,
visible,
disabled,
shortcut: action.shortcut,
to,
};
}
@@ -154,6 +160,7 @@ export function actionToMenuItem(
icon,
visible,
disabled,
shortcut: action.shortcut,
href: action.target
? { url: action.url, target: action.target }
: action.url,
+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) =>
+2 -1
View File
@@ -1,4 +1,5 @@
/* oxlint-disable react/prop-types */
import { observer } from "mobx-react";
import * as React from "react";
import type { Props as TooltipProps } from "~/components/Tooltip";
import Tooltip from "~/components/Tooltip";
@@ -85,4 +86,4 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
}
);
export default ActionButton;
export default observer(ActionButton);
+1 -1
View File
@@ -1,6 +1,6 @@
/* oxlint-disable prefer-rest-params */
/* global ga */
import escape from "lodash/escape";
import { escape } from "es-toolkit/compat";
import * as React from "react";
import type { PublicEnv } from "@shared/types";
import { IntegrationService } from "@shared/types";
+25 -8
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -16,6 +16,7 @@ const Authenticated = ({ children }: Props) => {
const { i18n } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const language = user?.language;
const hasLoggedOut = useRef(false);
// Watching for language changes here as this is the earliest point we might have the user
// available and means we can start loading translations faster
@@ -23,20 +24,36 @@ const Authenticated = ({ children }: Props) => {
void changeLanguage(language, i18n);
}, [i18n, language]);
const shouldLogout = !auth.authenticated && !auth.isFetching;
// Passive logout when we land here without an authenticated session note we
// intentionally do not revoke the server-side token, as that would clobber
// the session in any other tab that may have already re-authenticated.
useEffect(() => {
if (shouldLogout && !hasLoggedOut.current) {
hasLoggedOut.current = true;
void auth.logout({
savePath: true,
clearCache: false,
revokeToken: false,
});
}
}, [shouldLogout, auth]);
useEffect(() => {
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
}
}, [auth.logoutRedirectUri]);
if (auth.authenticated) {
return children;
}
if (auth.isFetching) {
if (auth.isFetching || auth.logoutRedirectUri) {
return <LoadingIndicator />;
}
void auth.logout({ savePath: true });
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
return null;
}
return <Redirect to="/" />;
};
+41 -14
View File
@@ -1,21 +1,27 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route } from "react-router-dom";
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";
import { RightSidebarProvider } from "~/components/RightSidebarContext";
import Sidebar from "~/components/Sidebar";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import history from "~/utils/history";
import { isModKey } from "@shared/utils/keyboard";
import lazyWithRetry from "~/utils/lazyWithRetry";
import {
searchPath,
newDocumentPath,
settingsPath,
homePath,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
@@ -33,11 +39,18 @@ type Props = {
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
useKeyDown(".", (event) => {
if (isModKey(event)) {
ui.toggleCollapsedSidebar();
}
});
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
@@ -60,7 +73,15 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
React.useEffect(() => {
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
history.replace(postLoginPath);
try {
history.replace(postLoginPath);
} catch (err) {
Logger.warn("Failed to navigate to post login path, falling back", {
path: postLoginPath,
error: err,
});
history.replace(homePath());
}
}
}, [spendPostLoginPath]);
@@ -68,12 +89,16 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const isSettings = location.pathname.startsWith(settingsPath());
const sidebar = (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
<React.Suspense fallback={null}>
{isSettings && <SettingsSidebar />}
</React.Suspense>
<div style={isSettings ? { display: "none" } : undefined}>
<Sidebar />
</div>
</Fade>
);
@@ -81,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>
+3 -6
View File
@@ -113,19 +113,16 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
${ellipsis()}
${undraggableOnDesktop()}
display: flex;
flex-shrink: 1;
min-width: 0;
cursor: var(--pointer);
color: ${s("text")};
font-size: 15px;
height: 24px;
line-height: 24px;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
svg {
flex-shrink: 0;
}
margin-inline-start: ${(props) => (props.$withIcon ? "4px" : "0")};
max-width: 460px;
&:hover {
text-decoration: underline;
+3 -3
View File
@@ -125,7 +125,7 @@ const Label = styled.span<{ hasIcon?: boolean }>`
white-space: nowrap;
text-overflow: ellipsis;
${(props) => props.hasIcon && "padding-left: 4px;"};
${(props) => props.hasIcon && "padding-inline-start: 4px;"};
`;
export const Inner = styled.span<{
@@ -135,13 +135,13 @@ export const Inner = styled.span<{
}>`
display: flex;
padding: 0 8px;
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
padding-inline-end: ${(props) => (props.disclosure ? 2 : 8)}px;
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
justify-content: center;
align-items: center;
min-height: 32px;
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && props.hasText && "padding-inline-start: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
+1 -3
View File
@@ -22,9 +22,7 @@ const Circle = ({
if (percentage) {
// because the circle is so small, anything greater than 85% appears like 100%
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
strokePercentage = percentage
? ((100 - percentage) * circumference) / 100
: 0;
strokePercentage = ((100 - percentage) * circumference) / 100;
}
return (
+11 -7
View File
@@ -1,7 +1,4 @@
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { filter, isEqual, orderBy, uniq } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -82,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(() => {
@@ -146,7 +143,14 @@ function Collaborators(props: Props) {
/>
);
},
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
[
presentIds,
editingIds,
observingUserId,
currentUserId,
handleAvatarClick,
t,
]
);
if (!document.insightsEnabled) {
+1 -1
View File
@@ -64,7 +64,7 @@ const StyledTrigger = styled(RadixCollapsible.Trigger)`
padding: 0 0 8px 0;
cursor: var(--pointer);
color: ${s("textTertiary")};
font-size: 14pxte
font-size: 14px;
&:hover {
color: ${s("textSecondary")};
+11 -4
View File
@@ -1,4 +1,4 @@
import uniq from "lodash/uniq";
import { uniq } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { useMemo, useEffect, useCallback, Suspense } from "react";
import { Controller, useForm } from "react-hook-form";
@@ -25,10 +25,11 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
import { HStack } from "../primitives/HStack";
import { useDialogContext } from "~/components/DialogContext";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
export interface FormData {
export type FormData = {
name: string;
icon: string;
color: string | null;
@@ -36,7 +37,7 @@ export interface FormData {
permission: CollectionPermission | undefined;
commenting?: boolean | null;
templateManagement: CollectionPermission;
}
};
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
@@ -67,6 +68,7 @@ export const CollectionForm = observer(function CollectionForm_({
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const dialog = useDialogContext();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
@@ -278,7 +280,12 @@ export const CollectionForm = observer(function CollectionForm_({
{collection ? (
options
) : (
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
<Collapsible
label={t("Advanced options")}
onOpenChange={() => dialog.setAnimating(true)}
>
{options}
</Collapsible>
)}
<HStack justify="flex-end">
+9 -6
View File
@@ -3,7 +3,7 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import { normalizeKeyDisplay, shortcutSeparator } from "@shared/utils/keyboard";
import Highlight from "~/components/Highlight";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
@@ -30,8 +30,8 @@ function CommandBarItem(
) {
const theme = useTheme();
const ancestors = React.useMemo(() => {
if (!currentRootActionId) {
return action.ancestors;
if (!currentRootActionId || !action.ancestors) {
return action.ancestors ?? [];
}
const index = action.ancestors.findIndex(
(ancestor) => ancestor.id === currentRootActionId
@@ -90,9 +90,12 @@ function CommandBarItem(
) : (
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
))}
{sc.split("+").flatMap((key, i, arr) => {
const el = <Key key={key}>{normalizeKeyDisplay(key)}</Key>;
return i < arr.length - 1 && shortcutSeparator
? [el, shortcutSeparator]
: [el];
})}
</React.Fragment>
))}
</Shortcut>
@@ -43,7 +43,8 @@ const Container = styled.div`
const Header = styled(Text).attrs({ as: "h3" })`
letter-spacing: 0.03em;
margin: 0;
padding: 16px 0 4px 20px;
padding-block: 16px 4px;
padding-inline: 20px 0;
height: 36px;
cursor: default;
`;
@@ -1,5 +1,5 @@
import { useKBar } from "kbar";
import escapeRegExp from "lodash/escapeRegExp";
import { escapeRegExp } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
@@ -1,13 +1,16 @@
import { DocumentIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { createInternalLinkAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
const useRecentDocumentActions = (count = 6) => {
const { documents, ui } = useStores();
const { t } = useTranslation();
return useMemo(
() =>
@@ -19,6 +22,7 @@ const useRecentDocumentActions = (count = 6) => {
name: item.titleWithDefault,
analyticsName: "Recently viewed document",
section: RecentSection,
description: documentBreadcrumbText(item, t),
icon: item.icon ? (
<Icon
value={item.icon}
@@ -26,12 +30,12 @@ const useRecentDocumentActions = (count = 6) => {
color={item.color ?? undefined}
/>
) : (
<DocumentIcon />
<DocumentIcon outline={item.isDraft} />
),
to: documentPath(item),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed]
[count, ui.activeDocumentId, documents.recentlyViewed, t]
);
};
+10 -9
View File
@@ -87,22 +87,23 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
}));
const wrappedEvent =
(
callback:
| React.FocusEventHandler<HTMLSpanElement>
| React.FormEventHandler<HTMLSpanElement>
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
<E extends React.SyntheticEvent<HTMLSpanElement>>(
callback: ((event: E) => void) | undefined
) =>
(event: any) => {
(event: E) => {
if (readOnly) {
return;
}
const text = event.currentTarget.textContent || "";
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event?.preventDefault();
if (
maxLength &&
event.nativeEvent instanceof KeyboardEvent &&
isPrintableKeyEvent(event.nativeEvent) &&
text.length >= maxLength
) {
event.preventDefault();
return;
}
@@ -120,7 +120,7 @@ const DefaultCollectionInputSelect = observer(
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
labelHidden
short
/>
);
+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 {
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useContext, useMemo, useState } from "react";
export type DialogContext = {
animating: boolean;
setAnimating: (isAnimating: boolean) => void;
};
/**
* Context for the dialogs (Guide/Modal) being rendered.
* This helps control the dialog's behavior from within any nested component.
*/
const DialogContext = createContext<DialogContext>({
animating: false,
setAnimating: () => {},
});
export function DialogProvider({ children }: { children: React.ReactNode }) {
const [animating, setAnimating] = useState(false);
const ctx = useMemo<DialogContext>(
() => ({
animating,
setAnimating,
}),
[animating]
);
return (
<DialogContext.Provider value={ctx}>{children}</DialogContext.Provider>
);
}
export const useDialogContext = () => useContext(DialogContext);
+30 -27
View File
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { DialogProvider } from "./DialogContext";
const Guide = lazyWithRetry(() => import("~/components/Guide"));
const Modal = lazyWithRetry(() => import("~/components/Modal"));
@@ -12,33 +13,35 @@ function Dialogs() {
const modals = [...modalStack];
return (
<Suspense fallback={null}>
{guide ? (
<Guide
isOpen={guide.isOpen}
onRequestClose={dialogs.closeGuide}
title={guide.title}
>
{guide.content}
</Guide>
) : undefined}
{modals.map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title}
style={modal.style}
width={modal.width}
height={modal.height}
>
{modal.content}
</Modal>
))}
</Suspense>
<DialogProvider>
<Suspense fallback={null}>
{guide ? (
<Guide
isOpen={guide.isOpen}
onRequestClose={dialogs.closeGuide}
title={guide.title}
>
{guide.content}
</Guide>
) : undefined}
{modals.map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title}
style={modal.style}
width={modal.width}
height={modal.height}
>
{modal.content}
</Modal>
))}
</Suspense>
</DialogProvider>
);
}
+73 -36
View File
@@ -1,10 +1,10 @@
import type { TFunction } from "i18next";
import { observer } from "mobx-react";
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
@@ -20,6 +20,56 @@ import { archivePath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
/**
* Returns the breadcrumb parts leading up to a document, separating the
* (possibly deleted) collection label from ancestor document titles. The
* document itself is not included.
*
* @param document - the document to compute the breadcrumb for.
* @param t - translation function for fallback titles.
* @returns the collection label and ancestor titles.
*/
export function documentBreadcrumbParts(
document: Document,
t: TFunction
): { collection: string | undefined; ancestors: string[] } {
let collectionLabel: string | undefined;
if (document.isCollectionDeleted) {
collectionLabel = t("Deleted Collection");
} else if (document.collection?.name) {
collectionLabel = document.collection.name;
}
return {
collection: collectionLabel,
ancestors: document.pathTo
.slice(0, -1)
.map((node) => node.title || t("Untitled")),
};
}
/**
* Returns the breadcrumb path leading up to a document as a plain text
* string. Includes the collection name (or "Deleted Collection" fallback)
* and any ancestor document titles, slash-separated.
*
* @param document - the document to compute the breadcrumb for.
* @param t - translation function for fallback titles.
* @returns the breadcrumb as a slash-separated string, or undefined if the
* document has no resolvable parent context.
*/
export function documentBreadcrumbText(
document: Document,
t: TFunction
): string | undefined {
const parts = documentBreadcrumbParts(document, t);
const segments = [
...(parts.collection ? [parts.collection] : []),
...parts.ancestors,
];
return segments.length ? segments.join(" / ") : undefined;
}
type Props = {
children?: React.ReactNode;
document: Document;
@@ -101,11 +151,16 @@ function DocumentBreadcrumb(
<DocumentName
documentId={node.id}
collection={collection}
icon={node.icon}
color={node.color}
title={title}
/>
),
icon: node.icon ? (
<Icon
value={node.icon}
color={node.color}
initial={title.charAt(0).toUpperCase()}
/>
) : undefined,
section: ActiveDocumentSection,
to: {
pathname: node.url,
@@ -142,22 +197,25 @@ function DocumentBreadcrumb(
return <></>;
}
const slicedPath = reverse
? path.slice(depth && -depth)
: path.slice(0, depth);
const { collection: collectionLabel, ancestors: ancestorLabels } =
documentBreadcrumbParts(document, t);
const slicedAncestors = reverse
? ancestorLabels.slice(depth && -depth)
: ancestorLabels.slice(0, depth);
const showCollection =
collection &&
(!reverse || depth === undefined || slicedPath.length < depth);
!!collectionLabel &&
(!reverse || depth === undefined || slicedAncestors.length < depth);
return (
<>
{showCollection && collection.name}
{slicedPath.map((node: NavigationNode, index: number) => (
<React.Fragment key={node.id}>
{showCollection && collectionLabel}
{slicedAncestors.map((label, index) => (
<React.Fragment key={index}>
{showCollection && <SmallSlash />}
{node.title || t("Untitled")}
{!showCollection && index !== slicedPath.length - 1 && (
{label}
{!showCollection && index !== slicedAncestors.length - 1 && (
<SmallSlash />
)}
</React.Fragment>
@@ -197,14 +255,10 @@ const CollectionName = observer(function CollectionName_({
const DocumentName = observer(function DocumentName_({
documentId,
collection,
icon,
color,
title,
}: {
documentId: string;
collection: Collection | undefined;
icon: string | undefined;
color: string | undefined;
title: string;
}) {
const { t } = useTranslation();
@@ -212,21 +266,8 @@ const DocumentName = observer(function DocumentName_({
const doc = documents.get(documentId);
const menuAction = useDocumentMenuAction({ documentId });
const content = icon ? (
<>
<StyledIcon
value={icon}
color={color}
initial={title.charAt(0).toUpperCase()}
/>{" "}
{title}
</>
) : (
title
);
if (!doc) {
return <>{content}</>;
return <>{title}</>;
}
return (
@@ -236,16 +277,12 @@ const DocumentName = observer(function DocumentName_({
}}
>
<ContextMenu action={menuAction} ariaLabel={t("Document options")}>
<span>{content}</span>
<span>{title}</span>
</ContextMenu>
</ActionContextProvider>
);
});
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
+1 -1
View File
@@ -110,7 +110,7 @@ function DocumentCard(props: Props) {
dir={document.dir}
$isDragging={isDragging}
to={{
pathname: document.url,
pathname: document.path,
state: {
title: document.titleWithDefault,
},
@@ -40,8 +40,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
return nodes;
}, [policies, collectionTrees]);
const copy = async () => {
if (!selectedPath) {
const copy = async (path = selectedPath) => {
if (!path) {
toast.message(t("Select a location to copy"));
return;
}
@@ -52,10 +52,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
publish,
recursive,
title: document.title,
collectionId: selectedPath.collectionId,
...(selectedPath.type === "document"
? { parentDocumentId: selectedPath.id }
: {}),
collectionId: path.collectionId,
...(path.type === "document" ? { parentDocumentId: path.id } : {}),
});
toast.success(t("Document copied"));
@@ -111,7 +109,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
t("Select a location to copy")
)}
</Text>
<Button disabled={!selectedPath || copying} onClick={copy}>
<Button disabled={!selectedPath || copying} onClick={() => copy()}>
{copying ? `${t("Copying")}` : t("Copy")}
</Button>
</Footer>
@@ -1,11 +1,13 @@
import FuzzySearch from "fuzzy-search";
import concat from "lodash/concat";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import flatten from "lodash/flatten";
import includes from "lodash/includes";
import map from "lodash/map";
import {
concat,
difference,
fill,
filter,
flatten,
includes,
map,
} from "es-toolkit/compat";
import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
@@ -22,7 +24,6 @@ import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
@@ -30,7 +31,7 @@ import useStores from "~/hooks/useStores";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
onSubmit: () => void;
onSubmit: (item: NavigationNode | null) => void;
/** A side-effect of item selection */
onSelect: (item: NavigationNode | null) => void;
/** Items to be shown in explorer */
@@ -254,6 +255,13 @@ function DocumentExplorer({
}
};
const submitNode = (node: number) => {
const selectedNode = nodes[node];
selectNode(selectedNode);
onSubmit(selectedNode);
};
const ListItem = observer(
({
index,
@@ -310,7 +318,8 @@ function DocumentExplorer({
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
onClick={() => selectNode(nodes[index])}
onDoubleClick={() => submitNode(index)}
icon={renderedIcon}
title={title}
path={path}
@@ -324,7 +333,8 @@ function DocumentExplorer({
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
onClick={() => selectNode(nodes[index])}
onDoubleClick={() => submitNode(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
@@ -386,7 +396,7 @@ function DocumentExplorer({
}
case "Enter": {
if (isModKey(ev)) {
onSubmit();
onSubmit(selectedNode);
} else {
toggleSelect(activeNode);
}
@@ -447,10 +457,7 @@ const FlexContainer = styled(Flex)`
justify-content: center;
`;
const ListSearch = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
const ListSearch = styled(InputSearch).attrs({ round: true })`
margin-bottom: 4px;
padding-left: 24px;
padding-right: 24px;
@@ -9,18 +9,30 @@ import Disclosure from "~/components/Sidebar/components/Disclosure";
import Text from "~/components/Text";
type Props = {
/** Whether this node is the chosen destination (committed pick via click or Enter). */
selected: boolean;
/** Whether this node is currently highlighted by pointer hover or keyboard navigation. */
active: boolean;
/** Inline style passed in by the virtualized list for absolute positioning. */
style: React.CSSProperties;
/** Whether this node's children are currently revealed in the tree. */
expanded: boolean;
/** Icon rendered before the title (document icon, emoji, or star). */
icon?: React.ReactNode;
/** Display title for the node. */
title: string;
/** Zero-based nesting depth, used to indent the node. */
depth: number;
/** Whether this node has descendants and should render a disclosure chevron. */
hasChildren: boolean;
/** Fired when the disclosure chevron is clicked to expand or collapse the node. */
onDisclosureClick: (ev: React.MouseEvent) => void;
/** Fired on pointer movement over the node; used to update the active highlight. */
onPointerMove: (ev: React.MouseEvent) => void;
/** Fired when the node is clicked to select it. */
onClick: (ev: React.MouseEvent) => void;
/** Fired when the node is double-clicked to submit the current selection. */
onDoubleClick: (ev: React.MouseEvent) => void;
};
function DocumentExplorerNode(
@@ -36,12 +48,13 @@ function DocumentExplorerNode(
onDisclosureClick,
onPointerMove,
onClick,
onDoubleClick,
}: Props,
ref: React.RefObject<HTMLSpanElement>
) {
const { t } = useTranslation();
const DISCLOSURE = 24;
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
const width = (depth + 2) * DISCLOSURE;
return (
<Node
@@ -49,6 +62,7 @@ function DocumentExplorerNode(
selected={selected}
active={active}
onClick={onClick}
onDoubleClick={onDoubleClick}
style={style}
onPointerMove={onPointerMove}
role="option"
@@ -79,6 +93,10 @@ const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin: 2px 0;
&&[aria-expanded="true"]:not(:hover) {
background: none;
}
`;
const Spacer = styled(Flex)<{ width: number }>`
@@ -17,6 +17,7 @@ type Props = {
onPointerMove: (ev: React.MouseEvent) => void;
onClick: (ev: React.MouseEvent) => void;
onDoubleClick: (ev: React.MouseEvent) => void;
};
function DocumentExplorerSearchResult({
@@ -28,6 +29,7 @@ function DocumentExplorerSearchResult({
path,
onPointerMove,
onClick,
onDoubleClick,
}: Props) {
const { t } = useTranslation();
@@ -36,6 +38,7 @@ function DocumentExplorerSearchResult({
selected={selected}
active={active}
onClick={onClick}
onDoubleClick={onDoubleClick}
style={style}
onPointerMove={onPointerMove}
role="option"
@@ -56,17 +56,17 @@ function DocumentMove({ document }: Props) {
return nodes;
}, [policies, collectionTrees, document.id]);
const move = async () => {
if (!selectedPath) {
const move = async (path = selectedPath) => {
if (!path) {
toast.message(t("Select a location to move"));
return;
}
try {
setMoving(true);
const { type, id: parentDocumentId } = selectedPath;
const { type, id: parentDocumentId } = path;
const collectionId = selectedPath.collectionId as string;
const collectionId = path.collectionId as string;
if (type === "document") {
await document.move({ collectionId, parentDocumentId });
@@ -103,7 +103,7 @@ function DocumentMove({ document }: Props) {
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath || moving} onClick={move}>
<Button disabled={!selectedPath || moving} onClick={() => move()}>
{moving ? `${t("Moving")}` : t("Move")}
</Button>
</Footer>
@@ -33,15 +33,14 @@ function TemplateMove({ template }: Props) {
[policies, collectionTrees]
);
const move = async () => {
if (!selectedPath) {
const move = async (path = selectedPath) => {
if (!path) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = (selectedPath.collectionId ??
selectedPath.id) as string;
const collectionId = (path.collectionId ?? path.id) as string;
await template.save({ collectionId });
toast.success(t("Template moved"));
@@ -76,7 +75,7 @@ function TemplateMove({ template }: Props) {
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={move}>
<Button disabled={!selectedPath} onClick={() => move()}>
{t("Move")}
</Button>
</Footer>
+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;
+1 -2
View File
@@ -1,5 +1,4 @@
import compact from "lodash/compact";
import sortBy from "lodash/sortBy";
import { compact, sortBy } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
+10 -17
View File
@@ -1,8 +1,9 @@
import difference from "lodash/difference";
import { difference } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { mergeRefs } from "react-merge-refs";
import type { Optional } from "utility-types";
@@ -16,7 +17,6 @@ import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useCurrentUser from "~/hooks/useCurrentUser";
import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
@@ -28,12 +28,7 @@ const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
export type Props = Optional<
EditorProps,
| "placeholder"
| "defaultValue"
| "onClickLink"
| "embeds"
| "dictionary"
| "extensions"
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "extensions"
> & {
embedsDisabled?: boolean;
onSynced?: () => Promise<void>;
@@ -52,7 +47,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
} = props;
const { comments } = useStores();
const { shareId } = useShare();
const dictionary = useDictionary();
const { t } = useTranslation();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
@@ -95,11 +90,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const handleFileUploadStart = React.useCallback(() => {
uploadState.current.timeoutId = setTimeout(() => {
uploadState.current.toastId = toast.loading(
dictionary.uploadingWithProgress(0)
t("Uploading… {{ progress }}%", { progress: 0 })
);
}, 2000);
onFileUploadStart?.();
}, [onFileUploadStart, dictionary.uploadingWithProgress]);
}, [onFileUploadStart, t]);
const handleFileUploadProgress = React.useCallback(
(fileId: string, fractionComplete: number) => {
@@ -113,12 +108,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
// Update toast if visible
if (uploadState.current.toastId) {
toast.loading(dictionary.uploadingWithProgress(percent), {
toast.loading(t("Uploading… {{ progress }}%", { progress: percent }), {
id: uploadState.current.toastId,
});
}
},
[dictionary.uploadingWithProgress]
[t]
);
const handleFileUploadStop = React.useCallback(() => {
@@ -183,7 +178,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
onFileUploadStart: handleFileUploadStart,
onFileUploadStop: handleFileUploadStop,
onFileUploadProgress: handleFileUploadProgress,
dictionary,
isAttachment,
});
},
@@ -192,7 +186,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
handleFileUploadStart,
handleFileUploadStop,
handleFileUploadProgress,
dictionary,
handleUploadFile,
]
);
@@ -211,6 +204,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const commentMarks = localRef.current.getComments();
const commentIds = comments.orderedData.map((c) => c.id);
const commentMarkIds = commentMarks?.map((c) => c.id);
const focus = previousCommentIds.current !== undefined;
const newCommentIds = difference(
commentMarkIds,
previousCommentIds.current ?? [],
@@ -220,7 +214,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
newCommentIds.forEach((commentId) => {
const mark = commentMarks.find((c) => c.id === commentId);
if (mark) {
onCreateCommentMark(mark.id, mark.userId);
onCreateCommentMark(mark.id, mark.userId, { focus });
}
});
@@ -288,7 +282,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
+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;
};
-233
View File
@@ -1,233 +0,0 @@
import * as React from "react";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { getDataTransferFiles } from "@shared/utils/files";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input, { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { compressImage } from "~/utils/compressImage";
import { generateEmojiNameFromFilename } from "~/utils/emoji";
import { AttachmentValidation, EmojiValidation } from "@shared/validations";
import { bytesToHumanReadable } from "@shared/utils/files";
import { VStack } from "./primitives/VStack";
type Props = {
onSubmit: () => void;
};
export function EmojiCreateDialog({ onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [name, setName] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const handleFileSelection = React.useCallback(
(file: File) => {
const isValidType = AttachmentValidation.emojiContentTypes.includes(
file.type
);
if (!isValidType) {
toast.error(
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
);
return;
}
// Validate file size
if (file.size > AttachmentValidation.emojiMaxFileSize) {
toast.error(
t("File size too large. Maximum size is {{ size }}.", {
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
})
);
return;
}
setFile(file);
// Auto-populate name field if it's empty
setName((currentName) => {
if (!currentName.trim()) {
const generatedName = generateEmojiNameFromFilename(file.name);
return generatedName || currentName;
}
return currentName;
});
},
[t]
);
const onDrop = React.useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
handleFileSelection(acceptedFiles[0]);
}
},
[handleFileSelection]
);
// Handle paste events
React.useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const files = getDataTransferFiles(event);
if (files.length > 0) {
event.preventDefault();
handleFileSelection(files[0]);
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [handleFileSelection]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDropAccepted: onDrop,
accept: AttachmentValidation.emojiContentTypes,
maxSize: AttachmentValidation.emojiMaxFileSize,
maxFiles: 1,
});
const handleSubmit = async () => {
if (!name.trim()) {
toast.error(t("Please enter a name for the emoji"));
return;
}
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
// Skip compression for GIFs to preserve animation
const fileToUpload =
file.type === "image/gif"
? file
: await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(fileToUpload, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
await emojis.create({
name: name.trim(),
attachmentId: attachment.id,
});
toast.success(t("Emoji created successfully"));
onSubmit();
} finally {
setIsUploading(false);
}
};
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setName(value);
};
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
const isValid = name.trim().length > 0 && file && isValidName;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"Square images with transparent backgrounds work best. If your image is too large, well try to resize it for you."
)}
</Text>
<LabelText as="label">{t("Upload an image")}</LabelText>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<VStack>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text size="medium">{file.name}</Text>
<Text size="medium" type="secondary">
{t("Click or drag to replace")}
</Text>
</>
) : (
<>
<Text size="medium">
{isDragActive
? t("Drop the image here")
: t("Click, drop, or paste an image here")}
</Text>
<Text size="medium" type="secondary">
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
size: bytesToHumanReadable(
AttachmentValidation.emojiMaxFileSize
),
})}
</Text>
</>
)}
</VStack>
</DropZone>
<Input
label={t("Choose a name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
{name.trim() && isValidName && (
<Text type="secondary" style={{ marginTop: "8px" }}>
{t("This emoji will be available as")} <code>:{name}:</code>
</Text>
)}
</ConfirmationDialog>
);
}
const DropZone = styled.div`
border: 2px dashed ${s("inputBorder")};
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: var(--pointer);
transition: border-color 0.2s;
margin-bottom: 1em;
&:hover {
border-color: ${s("inputBorderFocused")};
}
`;
const PreviewImage = styled.img`
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 4px;
`;
+161
View File
@@ -0,0 +1,161 @@
import * as React from "react";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { getDataTransferFiles } from "@shared/utils/files";
import { bytesToHumanReadable } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Text from "~/components/Text";
import { VStack } from "~/components/primitives/VStack";
interface UseEmojiFileUploadOptions {
/** Optional callback fired after a valid file is selected. */
onFileSelected?: (file: File) => void;
}
/**
* Hook that manages emoji image file selection with validation, drag-and-drop,
* and paste support.
*/
export function useEmojiFileUpload(options?: UseEmojiFileUploadOptions) {
const { t } = useTranslation();
const [file, setFile] = React.useState<File | null>(null);
const handleFileSelection = React.useCallback(
(selected: File) => {
const isValidType = AttachmentValidation.emojiContentTypes.includes(
selected.type
);
if (!isValidType) {
toast.error(
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
);
return;
}
if (selected.size > AttachmentValidation.emojiMaxFileSize) {
toast.error(
t("File size too large. Maximum size is {{ size }}.", {
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
})
);
return;
}
setFile(selected);
options?.onFileSelected?.(selected);
},
[t, options]
);
const handleDrop = React.useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
handleFileSelection(acceptedFiles[0]);
}
},
[handleFileSelection]
);
React.useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const files = getDataTransferFiles(event);
if (files.length > 0) {
event.preventDefault();
handleFileSelection(files[0]);
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [handleFileSelection]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDropAccepted: handleDrop,
accept: AttachmentValidation.emojiContentTypes,
maxSize: AttachmentValidation.emojiMaxFileSize,
maxFiles: 1,
});
return { file, getRootProps, getInputProps, isDragActive };
}
interface EmojiImageDropZoneProps {
/** The currently selected file, if any. */
file: File | null;
/** Dropzone root props. */
getRootProps: ReturnType<typeof useDropzone>["getRootProps"];
/** Dropzone input props. */
getInputProps: ReturnType<typeof useDropzone>["getInputProps"];
/** Whether a drag is currently active. */
isDragActive: boolean;
}
/**
* Shared drop zone component for emoji image upload, showing either a file
* preview or placeholder text.
*/
export function EmojiImageDropZone({
file,
getRootProps,
getInputProps,
isDragActive,
}: EmojiImageDropZoneProps) {
const { t } = useTranslation();
return (
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<VStack>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text size="medium">{file.name}</Text>
<Text size="medium" type="secondary">
{t("Click or drag to replace")}
</Text>
</>
) : (
<>
<Text size="small">
{isDragActive
? t("Drop the image here")
: t("Click, drop, or paste an image here")}
</Text>
<Text size="small" type="secondary">
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
size: bytesToHumanReadable(
AttachmentValidation.emojiMaxFileSize
),
})}
</Text>
</>
)}
</VStack>
</DropZone>
);
}
const DropZone = styled.div`
border: 2px dashed ${s("inputBorder")};
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: var(--pointer);
transition: border-color 0.2s;
margin-bottom: 1em;
&:hover {
border-color: ${s("inputBorderFocused")};
}
`;
const PreviewImage = styled.img`
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 4px;
`;
@@ -0,0 +1,132 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AttachmentPreset } from "@shared/types";
import { EmojiValidation } from "@shared/validations";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input, { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { compressImage } from "~/utils/compressImage";
import { generateEmojiNameFromFilename } from "~/utils/emoji";
import { useEmojiFileUpload, EmojiImageDropZone } from "./Components";
interface Props {
/** Callback invoked after successful creation. */
onSubmit: () => void;
}
/**
* Dialog for creating a new custom emoji with image upload and name input.
*/
export function EmojiCreateDialog({ onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [name, setName] = React.useState("");
const [isUploading, setIsUploading] = React.useState(false);
const handleFileSelected = React.useCallback((selected: File) => {
setName((currentName) => {
if (!currentName.trim()) {
const generatedName = generateEmojiNameFromFilename(selected.name);
return generatedName || currentName;
}
return currentName;
});
}, []);
const { file, getRootProps, getInputProps, isDragActive } =
useEmojiFileUpload({ onFileSelected: handleFileSelected });
const handleSubmit = async () => {
if (!name.trim()) {
toast.error(t("Please enter a name for the emoji"));
return;
}
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
const fileToUpload =
file.type === "image/gif"
? file
: await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(fileToUpload, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
await emojis.create({
name: name.trim(),
attachmentId: attachment.id,
});
toast.success(t("Emoji created successfully"));
onSubmit();
} finally {
setIsUploading(false);
}
};
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setName(value);
};
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
const isValid = name.trim().length > 0 && file && isValidName;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you."
)}
</Text>
<LabelText as="label">{t("Upload an image")}</LabelText>
<EmojiImageDropZone
file={file}
getRootProps={getRootProps}
getInputProps={getInputProps}
isDragActive={isDragActive}
/>
<Input
label={t("Choose a name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
{name.trim() && isValidName && (
<Text type="secondary" style={{ marginTop: "8px" }}>
{t("This emoji will be available as")} <code>:{name}:</code>
</Text>
)}
</ConfirmationDialog>
);
}
@@ -0,0 +1,86 @@
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AttachmentPreset } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import type Emoji from "~/models/Emoji";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { compressImage } from "~/utils/compressImage";
import { useEmojiFileUpload, EmojiImageDropZone } from "./Components";
interface Props {
/** The emoji whose image is being replaced. */
emoji: Emoji;
/** Callback invoked after a successful replacement. */
onSubmit: () => void;
}
/**
* Dialog for replacing the image of an existing custom emoji.
*/
export function EmojiReplaceDialog({ emoji, onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [isUploading, setIsUploading] = React.useState(false);
const { file, getRootProps, getInputProps, isDragActive } =
useEmojiFileUpload();
const handleSubmit = async () => {
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
const fileToUpload =
file.type === "image/gif"
? file
: await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(fileToUpload, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
await emojis.update({
id: emoji.id,
attachmentId: attachment.id,
});
toast.success(t("Emoji replaced"));
onSubmit();
} finally {
setIsUploading(false);
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!file || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Save")}
>
<Text as="p" type="secondary">
<Trans
defaults="Upload a new image to replace the current one for <em>{{emojiName}}</em>. All existing uses of this emoji will be updated automatically."
values={{ emojiName: `:${emoji.name}:` }}
components={{ em: <code /> }}
/>
</Text>
<EmojiImageDropZone
file={file}
getRootProps={getRootProps}
getInputProps={getInputProps}
isDragActive={isDragActive}
/>
</ConfirmationDialog>
);
}
+16 -5
View File
@@ -1,8 +1,8 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import type { WithTranslation } from "react-i18next";
import { withTranslation, Trans } from "react-i18next";
import type { TFunction } from "i18next";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -18,20 +18,26 @@ import Storage from "@shared/utils/Storage";
import { deleteAllDatabases } from "~/utils/developer";
import Flex from "./Flex";
type Props = WithTranslation & {
interface OwnProps {
/** Whether to reload the page if a chunk fails to load. */
reloadOnChunkMissing?: boolean;
/** Whether to show a title heading. */
showTitle?: boolean;
/** The wrapping component to use. */
component?: React.ComponentType | string;
/** Children rendered when no error is present. */
children?: React.ReactNode;
}
type Props = OwnProps & {
t: TFunction;
};
const ERROR_TRACKING_KEY = "error-boundary-tracking";
const ERROR_TRACKING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
@observer
class ErrorBoundary extends React.Component<Props> {
class ErrorBoundaryClass extends React.Component<Props> {
@observable
error: Error | null | undefined;
@@ -223,4 +229,9 @@ const Pre = styled.pre`
white-space: pre-wrap;
`;
export default withTranslation()(ErrorBoundary);
function ErrorBoundary(props: OwnProps) {
const { t } = useTranslation();
return <ErrorBoundaryClass t={t} {...props} />;
}
export default ErrorBoundary;
+1 -1
View File
@@ -18,7 +18,7 @@ type Props = {
};
/**
* Wraps children in a <Fade> if loading is true on mount.
* Wraps children in a <Fade> if animate is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = useState(animate);
+124 -41
View File
@@ -1,16 +1,26 @@
import deburr from "lodash/deburr";
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;
+9 -3
View File
@@ -1,4 +1,4 @@
import throttle from "lodash/throttle";
import { throttle } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { MenuIcon } from "outline-icons";
import { transparentize } from "polished";
@@ -116,17 +116,23 @@ function Header(
const Breadcrumbs = styled("div")`
flex-grow: 1;
flex-basis: 0;
min-width: 0;
align-items: center;
padding-right: 8px;
padding-inline: 0 8px;
display: flex;
${breakpoint("tablet")`
min-width: auto;
`};
`;
const Actions = styled(Flex)`
flex-grow: 1;
flex-basis: 0;
min-width: auto;
padding-left: 8px;
padding-inline: 8px 0;
gap: 12px;
margin-inline-start: 8px;
${breakpoint("tablet")`
position: unset;
+6 -1
View File
@@ -1,11 +1,16 @@
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
const Heading = styled.h1<{ as?: string; centered?: boolean }>`
display: flex;
align-items: center;
user-select: none;
${(props) => (props.as ? "" : "margin-top: 6vh; font-weight: 600;")}
${(props) => (props.as ? "" : "margin-top: 3vh; font-weight: 600;")}
${(props) => (props.centered ? "text-align: center;" : "")}
${breakpoint("tablet")`
${(props: { as?: string }) => (props.as ? "" : "margin-top: 6vh;")}
`};
`;
export default Heading;
+1 -1
View File
@@ -1,4 +1,4 @@
import escapeRegExp from "lodash/escapeRegExp";
import { escapeRegExp } from "es-toolkit/compat";
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
+1 -1
View File
@@ -88,6 +88,7 @@ export const CardContent = styled.div`
// &:after — gradient mask for overflow text
export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
${sharedVars}
backdrop-filter: blur(10px);
background: ${s("menuBackground")};
padding: 16px;
@@ -112,7 +113,6 @@ export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
${(props) =>
props.fadeOut !== false
? `&:after {
${sharedVars}
content: "";
display: block;
position: absolute;
@@ -26,7 +26,7 @@ const HoverPreviewDocument = React.forwardRef(function HoverPreviewDocument_(
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
<Flex column gap={2}>
<Title>{title}</Title>
<Info>{lastActivityByViewer}</Info>
{lastActivityByViewer && <Info>{lastActivityByViewer}</Info>}
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
@@ -1,4 +1,4 @@
import concat from "lodash/concat";
import { concat } from "es-toolkit/compat";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -6,7 +6,7 @@ import type { EmojiSkinTone } from "@shared/types";
import { EmojiCategory, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
import { DisplayCategory } from "../utils";
import type { DataNode, EmojiNode } from "./GridTemplate";
import GridTemplate from "./GridTemplate";
@@ -1,20 +1,25 @@
import chunk from "lodash/chunk";
import compact from "lodash/compact";
import { chunk, compact } from "es-toolkit/compat";
import * as React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { Emoji } from "~/components/Emoji";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import { TRANSLATED_CATEGORIES } from "../utils";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
import { CustomEmoji } from "@shared/components/CustomEmoji";
/**
* icon/emoji size is 24px; and we add 4px padding on all sides,
* Desktop: 24px icon/emoji + 4px padding on all sides = 32px button.
* Mobile: 32px icon/emoji + 4px padding on all sides = 40px button, so
* roughly 8 emojis fit across a typical phone screen.
*/
const BUTTON_SIZE = 32;
const BUTTON_SIZE_DESKTOP = 32;
const BUTTON_SIZE_MOBILE = 40;
const ICON_SIZE_DESKTOP = 24;
const ICON_SIZE_MOBILE = 32;
type OutlineNode = {
type: IconType.SVG;
@@ -53,8 +58,11 @@ const GridTemplate = (
{ width, height, data, empty, onIconSelect }: Props,
ref: React.Ref<HTMLDivElement>
) => {
const isMobile = useMobile();
const buttonSize = isMobile ? BUTTON_SIZE_MOBILE : BUTTON_SIZE_DESKTOP;
const iconSize = isMobile ? ICON_SIZE_MOBILE : ICON_SIZE_DESKTOP;
// 24px padding for the Grid Container
const itemsPerRow = Math.floor((width - 24) / BUTTON_SIZE);
const itemsPerRow = Math.max(1, Math.floor((width - 24) / buttonSize));
const gridItems = compact(
data.flatMap((node) => {
@@ -84,7 +92,11 @@ const GridTemplate = (
onClick={() => onIconSelect({ id: item.name, value: item.name })}
style={{ "--delay": `${item.delay}ms` } as React.CSSProperties}
>
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
<Icon
as={IconLibrary.getComponent(item.name)}
color={item.color}
size={iconSize}
>
{item.initial}
</Icon>
</IconButton>
@@ -96,7 +108,11 @@ const GridTemplate = (
key={item.id}
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji width={24} height={24}>
<Emoji
width={iconSize}
height={iconSize}
size={isMobile ? iconSize : undefined}
>
{item.type === IconType.Custom ? (
<CustomEmoji value={item.value} title={item.name} />
) : (
@@ -119,7 +135,7 @@ const GridTemplate = (
height={height}
data={gridItems}
columns={itemsPerRow}
itemWidth={BUTTON_SIZE}
itemWidth={buttonSize}
/>
);
};
@@ -1,5 +1,5 @@
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { breakpoints, s, hover } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
export const IconButton = styled(NudeButton)<{ delay?: number }>`
@@ -10,4 +10,9 @@ export const IconButton = styled(NudeButton)<{ delay?: number }>`
&: ${hover} {
background: ${s("listItemHoverBackground")};
}
@media (max-width: ${breakpoints.tablet - 1}px) {
width: 40px;
height: 40px;
}
`;
+3 -2
View File
@@ -79,7 +79,9 @@ const IconPicker = ({
const [activeTab, setActiveTab] = React.useState<TabName>(defaultTab);
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
// The Drawer's inner content has 6px padding on each side; subtract it
// so the panel doesn't overflow horizontally and itemsPerRow is correct.
const popoverWidth = isMobile ? windowWidth - 12 : POPOVER_WIDTH;
const handleTabChange = React.useCallback((value: string) => {
setActiveTab(value as TabName);
@@ -105,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);
+100 -5
View File
@@ -6,11 +6,15 @@ import breakpoint from "styled-components-breakpoint";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import Fade from "~/components/Fade";
import { undraggableOnDesktop } from "~/styles";
export const NativeTextarea = styled.textarea<{
hasIcon?: boolean;
hasPrefix?: boolean;
$autoSize?: boolean;
$minHeight?: string;
$maxHeight?: string;
}>`
border: 0;
flex: 1;
@@ -20,6 +24,10 @@ export const NativeTextarea = styled.textarea<{
background: none;
color: ${s("text")};
${(props) => props.$autoSize && `field-sizing: content;`}
${(props) => props.$minHeight && `min-height: ${props.$minHeight};`}
${(props) => props.$maxHeight && `max-height: ${props.$maxHeight};`}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
@@ -87,7 +95,7 @@ export const Wrapper = styled.div<{
const IconWrapper = styled.span`
position: relative;
left: 4px;
inset-inline-start: 4px;
width: 24px;
height: 24px;
`;
@@ -96,7 +104,9 @@ export const Outline = styled(Flex)<{
margin?: string | number;
hasError?: boolean;
$focused?: boolean;
$round?: boolean;
}>`
position: relative;
flex: 1;
margin: ${(props) =>
props.margin !== undefined ? props.margin : "0 0 16px"};
@@ -109,7 +119,7 @@ export const Outline = styled(Flex)<{
: props.$focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
border-radius: 4px;
border-radius: ${(props) => (props.$round ? "16px" : "4px")};
font-weight: normal;
align-items: center;
overflow: hidden;
@@ -119,6 +129,24 @@ export const Outline = styled(Flex)<{
user-select: none;
`;
const CharacterCount = styled.span<{ $warning?: boolean }>`
position: absolute;
top: 0;
inset-inline-end: 0;
font-size: 11px;
line-height: 1;
padding: 2px 4px;
border-start-start-radius: 0;
border-start-end-radius: 0;
border-end-end-radius: 0;
border-end-start-radius: 2px;
background: ${(props) =>
props.$warning ? props.theme.warning : props.theme.inputBorder};
color: ${(props) =>
props.$warning ? props.theme.white : props.theme.textTertiary};
pointer-events: none;
`;
export const LabelText = styled.div`
font-weight: 500;
padding-bottom: 4px;
@@ -141,6 +169,18 @@ export interface Props extends Omit<
prefix?: React.ReactNode;
/** Optional icon that appears inside the input before the textarea */
icon?: React.ReactNode;
/** Show a character count near the maxLength limit. Always shown for textareas, opt-in for other types. */
showCharacterCount?: boolean;
/** An optional soft limit below maxLength. When the value exceeds this, the character count is shown in a warning color. */
warningLimit?: number;
/** For textareas, grow the height to fit content. Use with `maxHeight` to cap the growth. */
autoSize?: boolean;
/** Minimum height of the textarea as a CSS length value (e.g. "3lh", "80px"). */
minHeight?: string;
/** Maximum height of the textarea as a CSS length value (e.g. "20lh", "400px"). */
maxHeight?: string;
/** Whether to use a round border-radius (16px) instead of the default (4px). */
round?: boolean;
/** Like autoFocus, but also select any text in the input */
autoSelect?: boolean;
/** Callback is triggered with the CMD+Enter keyboard combo */
@@ -157,6 +197,21 @@ function Input(
) {
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
const [focused, setFocused] = React.useState(false);
const [charCount, setCharCount] = React.useState(() => {
if (typeof props.value === "string") {
return props.value.length;
}
if (typeof props.defaultValue === "string") {
return props.defaultValue.length;
}
return 0;
});
React.useEffect(() => {
if (typeof props.value === "string") {
setCharCount(props.value.length);
}
}, [props.value]);
const handleBlur = (ev: React.SyntheticEvent) => {
setFocused(false);
@@ -174,6 +229,15 @@ function Input(
}
};
const handleChange = (
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setCharCount(ev.target.value.length);
if (props.onChange) {
props.onChange(ev);
}
};
const handleKeyDown = (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
@@ -205,14 +269,31 @@ function Input(
short,
flex,
prefix,
round,
labelHidden,
maxLength,
showCharacterCount,
warningLimit,
autoSize,
minHeight,
maxHeight,
onFocus,
onBlur,
onChange,
onRequestSubmit,
children,
...rest
} = props;
const showCharCount =
(type === "textarea" || showCharacterCount) &&
maxLength !== undefined &&
(charCount >= maxLength * 0.9 ||
(warningLimit !== undefined && charCount >= warningLimit));
const overWarningLimit =
warningLimit !== undefined && charCount > warningLimit;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
@@ -224,7 +305,7 @@ function Input(
) : (
wrappedLabel
))}
<Outline $focused={focused} margin={margin}>
<Outline $focused={focused} $round={round} margin={margin}>
{prefix}
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
@@ -237,9 +318,14 @@ function Input(
onFocus={handleFocus}
hasIcon={!!icon}
hasPrefix={!!prefix}
$autoSize={autoSize}
$minHeight={minHeight}
$maxHeight={maxHeight}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
// set it after "rest" to override props from spread.
maxLength={maxLength}
onKeyDown={handleKeyDown}
onChange={handleChange}
/>
) : (
<NativeInput
@@ -253,10 +339,19 @@ function Input(
hasPrefix={!!prefix}
type={type}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
// set it after "rest" to override "onKeyDown" and "onChange" from prop.
maxLength={maxLength}
onKeyDown={handleKeyDown}
onChange={handleChange}
/>
)}
{showCharCount && (
<Fade>
<CharacterCount $warning={overWarningLimit}>
{charCount}/{maxLength}
</CharacterCount>
</Fade>
)}
{children}
</Outline>
</label>
+1 -1
View File
@@ -36,7 +36,7 @@ const PositionedSwatchButton = styled(SwatchButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
position: absolute;
bottom: 21px;
right: 6px;
inset-inline-end: 6px;
`;
export default InputColor;
@@ -39,7 +39,7 @@ export default function InputMemberPermissionSelect(
value={value || EmptySelectValue}
onChange={onChange}
label={t("Permissions")}
hideLabel
labelHidden
nude
{...rest}
/>
+35 -8
View File
@@ -4,11 +4,18 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { isModKey } from "@shared/utils/keyboard";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import {
isModKey,
metaDisplay,
shortcutSeparator,
} from "@shared/utils/keyboard";
import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input";
import Input from "./Input";
type Props = {
/** A string representing where the search started, for tracking. */
@@ -42,6 +49,7 @@ function InputSearchPage({
const theme = useTheme();
const history = useHistory();
const { t } = useTranslation();
const isMobile = useMobile();
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
useKeyDown("f", (ev: KeyboardEvent) => {
@@ -97,16 +105,35 @@ function InputSearchPage({
onBlur={setUnfocused}
margin={0}
labelHidden
/>
>
{!isMobile && (
<Shortcut $visible={!isFocused && !value && !collectionId}>
{metaDisplay}
{shortcutSeparator}K
</Shortcut>
)}
</InputMaxWidth>
);
}
const InputMaxWidth = styled(Input)`
max-width: 30vw;
const InputMaxWidth = styled(Input).attrs({ round: true })`
max-width: min(calc(30vw + 20px), 100%);
${Outline} {
border-radius: 16px;
}
/* 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 }>`
flex-shrink: 0;
font-size: 13px;
color: ${s("textTertiary")};
padding-inline: 0 10px;
pointer-events: none;
opacity: ${(props) => (props.$visible ? 1 : 0)};
transition: opacity 100ms ease-in-out;
`;
export default observer(InputSearchPage);
+57 -9
View File
@@ -21,6 +21,7 @@ import {
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectHeading,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
@@ -35,6 +36,13 @@ type Separator = {
type: "separator";
};
type Heading = {
/* Denotes a non-selectable heading rendered above a group of options. */
type: "heading";
/* Text shown as the heading label. */
label: string;
};
export type Item = {
/* Denotes a selectable option in the menu. */
type: "item";
@@ -48,7 +56,7 @@ export type Item = {
icon?: React.ReactElement;
};
export type Option = Item | Separator;
export type Option = Item | Separator | Heading;
type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
/* Options to display in the select menu. */
@@ -60,13 +68,15 @@ type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
labelHidden?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
short?: boolean;
/** Display a tooltip with the descriptive help text about the select menu. */
help?: string;
/** Render function to override the selected value shown in the trigger. Receives the currently selected option, or undefined when none is selected. */
displayValue?: (selectedOption: Item | undefined) => React.ReactNode;
} & TriggerButtonProps;
export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
@@ -76,9 +86,10 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
value,
onChange,
label,
hideLabel,
labelHidden,
short,
help,
displayValue,
...triggerProps
} = props;
@@ -95,12 +106,34 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
(opt) => opt.type === "item" && !!opt.icon
);
const selectedOption = React.useMemo(
() =>
localValue
? (options.find(
(opt) => opt.type === "item" && opt.value === localValue
) as Item | undefined)
: undefined,
[localValue, options]
);
const resolvedDisplayValue = displayValue
? displayValue(selectedOption)
: undefined;
const renderOption = React.useCallback(
(option: Option, idx: number) => {
if (option.type === "separator") {
return <InputSelectSeparator key={`separator-${idx}`} />;
}
if (option.type === "heading") {
return (
<InputSelectHeading key={`heading-${option.label}`}>
{option.label}
</InputSelectHeading>
);
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
@@ -143,13 +176,14 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
resolvedDisplayValue={resolvedDisplayValue}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} help={help} />
<Label text={label} hidden={labelHidden ?? false} help={help} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
@@ -159,6 +193,7 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
<InputSelectTrigger
ref={ref}
placeholder={placeholder}
displayValue={resolvedDisplayValue}
{...triggerProps}
/>
<InputSelectContent
@@ -179,6 +214,7 @@ InputSelect.displayName = "InputSelect";
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
resolvedDisplayValue?: React.ReactNode;
};
const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
@@ -188,11 +224,13 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
value,
onChange,
label,
hideLabel,
labelHidden,
disabled,
short,
placeholder,
optionsHaveIcon,
displayValue: _displayValue,
resolvedDisplayValue,
...triggerProps
} = props;
@@ -222,6 +260,14 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
return <InputSelectSeparator key={`separator-${idx}`} />;
}
if (option.type === "heading") {
return (
<InputSelectHeading key={`heading-${option.label}`}>
{option.label}
</InputSelectHeading>
);
}
const isSelected = option === selectedOption;
return (
@@ -252,7 +298,7 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Label text={label} hidden={labelHidden ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
@@ -262,7 +308,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
{resolvedDisplayValue !== undefined ? (
resolvedDisplayValue
) : selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
@@ -365,8 +413,8 @@ const IconWrapper = styled.span`
align-items: center;
width: 24px;
height: 24px;
margin-left: -4px;
margin-right: 4px;
margin-inline-start: -4px;
margin-inline-end: 4px;
overflow: hidden;
flex-shrink: 0;
`;
+1 -1
View File
@@ -11,7 +11,7 @@ type Props = {
shrink?: boolean;
} & Pick<
React.ComponentProps<typeof InputSelect>,
"value" | "onChange" | "disabled" | "hideLabel" | "nude" | "help"
"value" | "onChange" | "disabled" | "labelHidden" | "nude" | "help"
>;
export const InputSelectPermission = React.forwardRef<HTMLButtonElement, Props>(
+1 -1
View File
@@ -1,5 +1,5 @@
import { m } from "framer-motion";
import find from "lodash/find";
import { find } from "es-toolkit/compat";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { languages, languageOptions } from "@shared/i18n";
+5 -4
View File
@@ -48,6 +48,7 @@ const Layout = React.forwardRef(function Layout_(
<Content
auto
justify="center"
role="main"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
@@ -55,7 +56,7 @@ const Layout = React.forwardRef(function Layout_(
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
marginInlineStart: `${ui.sidebarWidth}px`,
}
}
>
@@ -85,21 +86,21 @@ type ContentProps = {
const Content = styled(Flex)<ContentProps>`
margin: 0;
transition: ${(props) =>
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
props.$isResizing ? "none" : `margin-inline-start 100ms ease-out`};
@media print {
margin: 0 !important;
}
${breakpoint("mobile", "tablet")`
margin-left: 0 !important;
margin-inline-start: 0 !important;
`}
${breakpoint("tablet")`
${(props: ContentProps) =>
props.$hasSidebar &&
props.$sidebarCollapsed &&
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
`margin-inline-start: ${props.theme.sidebarCollapsedWidth}px;`}
`};
`;
+1
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
// oxlint-disable no-explicit-any -- ComponentType<any> is the standard React pattern for generic component constraints
export interface LazyComponent<T extends React.ComponentType<any>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
+174 -12
View File
@@ -2,7 +2,12 @@ import { observer } from "mobx-react";
import * as Dialog from "@radix-ui/react-dialog";
import type { Keyframes } from "styled-components";
import styled, { css, keyframes } from "styled-components";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import type {
ComponentProps,
HTMLAttributes,
ReactNode,
SyntheticEvent,
} from "react";
import {
createContext,
forwardRef,
@@ -18,6 +23,7 @@ import { Error as ImageError } from "@shared/editor/components/Image";
import {
BackIcon,
CloseIcon,
CommentIcon,
CrossIcon,
DownloadIcon,
LinkIcon,
@@ -40,7 +46,7 @@ import CopyToClipboard from "./CopyToClipboard";
import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
import { toast } from "sonner";
import { findIndex } from "lodash";
import { findIndex } from "es-toolkit/compat";
import type { LightboxImage } from "@shared/editor/lib/Lightbox";
import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch";
import {
@@ -55,6 +61,9 @@ import { NodeSelection } from "prosemirror-state";
import { ImageSource } from "@shared/editor/lib/FileHelper";
import Desktop from "~/utils/Desktop";
import { HStack } from "./primitives/HStack";
import { useDocumentContext } from "./DocumentContext";
import LightboxComments from "~/scenes/Document/components/Comments/LightboxComments";
import { PortalContext } from "./Portal";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -88,6 +97,15 @@ type Animation = {
const ANIMATION_DURATION = 0.3 * Second.ms;
/**
* Stops a React synthetic event from propagating to ancestor handlers, including
* Radix Dialog's outside-interaction detection and the editor's own click
* handlers, so the comments sidebar can manage its own focus.
*/
const stopPropagation = (event: SyntheticEvent) => {
event.stopPropagation();
};
type Props = {
/** List of allowed images */
images: LightboxImage[];
@@ -225,6 +243,11 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
const [commentsOpen, setCommentsOpen] = useState(false);
const [commentsRendered, setCommentsRendered] = useState(false);
const [commentsVisible, setCommentsVisible] = useState(false);
const [commentsPortalEl, setCommentsPortalEl] =
useState<HTMLDivElement | null>(null);
const animation = useRef<Animation | null>(null);
const finalImage = useRef<{
center: { x: number; y: number };
@@ -233,6 +256,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
} | null>(null);
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
const editor = useEditor();
const { document: contextDocument } = useDocumentContext();
const activeNode = editor?.view?.state?.doc?.nodeAt(activeImage.pos);
const canShowComments =
!!contextDocument && activeNode?.type.name === "image";
const currentImageIndex = findIndex(
images,
@@ -312,6 +339,19 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
}
}, [status.lightbox]);
useEffect(() => {
if (commentsOpen) {
setCommentsRendered(true);
const frame = window.requestAnimationFrame(() =>
setCommentsVisible(true)
);
return () => window.cancelAnimationFrame(frame);
}
setCommentsVisible(false);
const timer = window.setTimeout(() => setCommentsRendered(false), 200);
return () => window.clearTimeout(timer);
}, [commentsOpen]);
useEffect(() => {
if (status.image === ImageStatus.MIN_ZOOM) {
// It was observed that focus went to `body` as the zoom out button was disabled
@@ -441,6 +481,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
status.image === ImageStatus.MAX_ZOOM
)
) {
// Refresh the cached natural image position to account for any layout
// changes (e.g., the comments sidebar opening) since the image loaded.
rememberImagePosition();
// in lightbox
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
@@ -632,17 +676,30 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
}, [activeImage, status.lightbox]);
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
ev.preventDefault();
// Don't intercept keys while typing into an input, textarea, or editor.
const target = ev.target as HTMLElement | null;
if (
target &&
target !== ev.currentTarget &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable)
) {
return;
}
switch (ev.key) {
case "ArrowLeft": {
ev.preventDefault();
prev();
break;
}
case "ArrowRight": {
ev.preventDefault();
next();
break;
}
case "Escape": {
ev.preventDefault();
close();
break;
}
@@ -698,14 +755,21 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
onAnimationStart={handleFadeStart}
onAnimationEnd={handleFadeEnd}
/>
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
<StyledContent
onKeyDown={handleKeyDown}
ref={contentRef}
$commentsOpen={canShowComments && commentsOpen}
>
<VisuallyHidden.Root>
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
<Dialog.Description>
{t("View, navigate, or download images in the document")}
</Dialog.Description>
</VisuallyHidden.Root>
<Actions animation={animation.current}>
<Actions
animation={animation.current}
$commentsOpen={canShowComments && commentsOpen}
>
<Tooltip content={t("Zoom in")} placement="bottom">
<ActionButton
tabIndex={-1}
@@ -788,7 +852,22 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
/>
</Tooltip>
)}
<Separator />
{canShowComments && (
<Tooltip content={t("Comments")} placement="bottom">
<ActionButton
tabIndex={-1}
onClick={() => setCommentsOpen((open) => !open)}
aria-label={t("Comments")}
aria-pressed={commentsOpen}
size={32}
icon={<CommentIcon />}
borderOnHover
neutral
/>
</Tooltip>
)}
</Actions>
<CloseAction animation={animation.current}>
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<ActionButton
@@ -802,7 +881,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
/>
</Tooltip>
</Dialog.Close>
</Actions>
</CloseAction>
{currentImageIndex > 0 &&
!(
status.image === ImageStatus.ZOOMED ||
@@ -878,12 +957,36 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
) && (
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<Nav
dir="right"
$hidden={isIdle}
animation={animation.current}
$commentsOpen={canShowComments && commentsOpen}
>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
</NavButton>
</Nav>
)}
{canShowComments && commentsRendered && contextDocument && (
<CommentsSidebar
ref={setCommentsPortalEl}
animation={animation.current}
$open={commentsVisible}
onPointerDown={stopPropagation}
onPointerUp={stopPropagation}
onMouseDown={stopPropagation}
onMouseUp={stopPropagation}
onClick={stopPropagation}
>
<PortalContext.Provider value={commentsPortalEl}>
<LightboxComments
document={contextDocument}
pos={activeImage.pos}
/>
</PortalContext.Provider>
</CommentsSidebar>
)}
</StyledContent>
</Dialog.Portal>
</Dialog.Root>
@@ -1090,7 +1193,7 @@ const StyledImg = styled.img<{
: ""}
`;
const StyledContent = styled(Dialog.Content)`
const StyledContent = styled(Dialog.Content)<{ $commentsOpen: boolean }>`
position: fixed;
inset: 0;
z-index: ${depths.modal};
@@ -1098,6 +1201,8 @@ const StyledContent = styled(Dialog.Content)`
justify-content: center;
align-items: center;
outline: none;
padding-inline-end: ${(props) => (props.$commentsOpen ? "360px" : "0")};
transition: padding-inline-end 200ms ease-out;
`;
const ActionButton = styled(Button)`
@@ -1106,15 +1211,45 @@ const ActionButton = styled(Button)`
const Actions = styled(HStack)<{
animation: Animation | null;
$commentsOpen: boolean;
}>`
position: absolute;
top: 0;
right: 0;
right: ${(props) => (props.$commentsOpen ? "360px" : "44px")};
margin: 16px 12px;
z-index: ${depths.modal};
background: ${(props) => transparentize(0.2, props.theme.background)};
backdrop-filter: blur(4px);
border-radius: 6px;
transition: right 200ms ease-out;
${(props) =>
props.animation === null
? css`
opacity: 0;
`
: props.animation.fadeIn
? css`
animation: ${props.animation.fadeIn.apply()}
${props.animation.fadeIn.duration}ms;
`
: props.animation.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const CloseAction = styled.div<{ animation: Animation | null }>`
position: fixed;
top: 0;
right: 0;
margin: 16px 12px;
z-index: ${depths.modal + 1};
background: ${(props) => transparentize(0.2, props.theme.background)};
backdrop-filter: blur(4px);
border-radius: 6px;
${(props) =>
props.animation === null
@@ -1138,10 +1273,16 @@ const Nav = styled.div<{
$hidden: boolean;
dir: "left" | "right";
animation: Animation | null;
$commentsOpen?: boolean;
}>`
position: absolute;
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
transition: opacity 500ms ease-in-out;
${(props) =>
props.dir === "left"
? "left: 0;"
: `right: ${props.$commentsOpen ? "360px" : "0"};`}
transition:
opacity 500ms ease-in-out,
right 200ms ease-out;
z-index: ${depths.modal};
${(props) => props.$hidden && "opacity: 0;"}
${(props) =>
@@ -1183,6 +1324,27 @@ const StyledError = styled(ImageError)<{
: ""}
`;
const CommentsSidebar = styled.div<{
animation: Animation | null;
$open: boolean;
}>`
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: ${depths.modal};
display: flex;
transform: translateX(${(props) => (props.$open ? "0" : "100%")});
transition: transform 200ms ease-out;
${(props) =>
props.animation?.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const NavButton = styled(NudeButton)`
margin: 16px;
opacity: 0.75;
+1 -1
View File
@@ -203,7 +203,7 @@ const Wrapper = styled.a<{
`;
const Image = styled(Flex)`
padding: 0 8px 0 0;
padding-inline-end: 8px;
max-height: 32px;
align-items: center;
user-select: none;
+1 -1
View File
@@ -1,4 +1,4 @@
import times from "lodash/times";
import { times } from "es-toolkit/compat";
import styled from "styled-components";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
+1 -1
View File
@@ -47,7 +47,7 @@ export const ContextMenu = observer(
onClose?.();
}
},
[open, onOpen, onClose]
[onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {
+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>
+13 -10
View File
@@ -42,13 +42,14 @@ export function toMenuItems(items: MenuItem[]) {
case "button":
return (
<MenuButton
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
tooltip={item.tooltip}
selected={item.selected}
dangerous={item.dangerous}
shortcut={item.shortcut}
onClick={item.onClick}
/>
);
@@ -56,10 +57,11 @@ export function toMenuItems(items: MenuItem[]) {
case "route":
return (
<MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
shortcut={item.shortcut}
to={item.to}
/>
);
@@ -67,10 +69,11 @@ export function toMenuItems(items: MenuItem[]) {
case "link":
return (
<MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
shortcut={item.shortcut}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
typeof item.href === "string" ? undefined : item.href.target
@@ -92,7 +95,7 @@ export function toMenuItems(items: MenuItem[]) {
};
return (
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenu key={`${item.type}-${index}`}>
<SubMenuTrigger
label={item.title as string}
icon={icon}
@@ -118,7 +121,7 @@ export function toMenuItems(items: MenuItem[]) {
return (
<MenuGroup
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
label={item.title as string}
items={groupItems}
/>
@@ -168,7 +171,7 @@ export function toMobileMenuItems(
case "button":
return (
<Components.MenuButton
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
disabled={item.disabled}
$dangerous={item.dangerous}
onClick={(e) => {
@@ -189,7 +192,7 @@ export function toMobileMenuItems(
case "route":
return (
<Components.MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
to={item.to}
disabled={item.disabled}
onClick={closeMenu}
@@ -202,7 +205,7 @@ export function toMobileMenuItems(
case "link":
return (
<Components.MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
typeof item.href === "string" ? undefined : item.href.target
@@ -228,7 +231,7 @@ export function toMobileMenuItems(
return (
<Components.MenuButton
key={`${item.type}-${item.title}-${index}`}
key={`${item.type}-${index}`}
disabled={item.disabled}
onClick={() => {
openSubmenu(item.title as string);
@@ -253,7 +256,7 @@ export function toMobileMenuItems(
}
return (
<div key={`${item.type}-${item.title}-${index}`}>
<div key={`${item.type}-${index}`}>
<Components.MenuHeader>{item.title}</Components.MenuHeader>
{groupItems}
</div>
+27 -19
View File
@@ -15,8 +15,8 @@ import usePrevious from "~/hooks/usePrevious";
import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import ErrorBoundary from "./ErrorBoundary";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import Tooltip from "./Tooltip";
import { useDialogContext } from "~/components/DialogContext";
type Props = {
children?: React.ReactNode;
@@ -31,7 +31,7 @@ type Props = {
const Modal: React.FC<Props> = ({
children,
isOpen,
title = "Untitled",
title,
style,
width,
height,
@@ -40,42 +40,43 @@ const Modal: React.FC<Props> = ({
const wasOpen = usePrevious(isOpen);
const isMobile = useMobile();
const { t } = useTranslation();
const resolvedTitle = title ?? t("Untitled");
const dialog = useDialogContext();
const onClose = React.useCallback(() => {
dialog.setAnimating(false); // Reset
onRequestClose();
}, [dialog, onRequestClose]);
if (!isOpen && !wasOpen) {
return null;
}
return (
<Dialog.Root
open={isOpen}
onOpenChange={(open) => !open && onRequestClose()}
>
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<StyledOverlay />
<Dialog.Title asChild>
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
</Dialog.Title>
<StyledContent
onEscapeKeyDown={onRequestClose}
onPointerDownOutside={onRequestClose}
onEscapeKeyDown={onClose}
onPointerDownOutside={onClose}
aria-describedby={undefined}
>
{isMobile ? (
<Mobile>
<MobileContent>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && (
<Dialog.Title asChild>
<Text size="xlarge" weight="bold">
{title}
{resolvedTitle}
</Text>
)}
</Dialog.Title>
<ErrorBoundary>{children}</ErrorBoundary>
</Centered>
</MobileContent>
<Close onClick={onRequestClose}>
<Close onClick={onClose}>
<CloseIcon size={32} />
</Close>
<Back onClick={onRequestClose}>
<Back onClick={onClose}>
<BackIcon size={32} />
<Text>{t("Back")} </Text>
</Back>
@@ -89,13 +90,20 @@ const Modal: React.FC<Props> = ({
column
reverse
>
<DesktopContent style={style} topShadow>
<DesktopContent
style={style}
topShadow
overflow={dialog.animating ? "hidden" : undefined}
onAnimationEnd={() => dialog.setAnimating(false)}
>
<ErrorBoundary component="div">{children}</ErrorBoundary>
</DesktopContent>
<Header>
{title && <Text size="large">{title}</Text>}
<Dialog.Title asChild>
<Text size="large">{resolvedTitle}</Text>
</Dialog.Title>
<Tooltip content={t("Close")} shortcut="Esc">
<NudeButton onClick={onRequestClose}>
<NudeButton onClick={onClose}>
<CloseIcon />
</NudeButton>
</Tooltip>
@@ -0,0 +1,121 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { DocumentPermission } from "@shared/types";
import type Notification from "~/models/Notification";
import { client } from "~/utils/ApiClient";
import Button from "../Button";
import Flex from "../Flex";
import { SplitButton } from "../primitives/SplitButton";
type Props = {
notification: Notification;
};
function AccessRequestActions({ notification }: Props) {
const { t } = useTranslation();
const [processing, setProcessing] = React.useState(false);
const [selectedPermission, setSelectedPermission] =
React.useState<DocumentPermission>(DocumentPermission.Read);
const permissionOptions = React.useMemo(
() => [
{
label: t("View only"),
value: DocumentPermission.Read,
description: t("Can view the document"),
},
{
label: t("Can edit"),
value: DocumentPermission.ReadWrite,
description: t("Can view and edit the document"),
},
{
label: t("Manage"),
value: DocumentPermission.Admin,
description: t("Full access including sharing"),
},
],
[t]
);
const selectedLabel = permissionOptions.find(
(o) => o.value === selectedPermission
)?.label;
const handleApprove = React.useCallback(async () => {
if (!notification.actor || processing) {
return;
}
setProcessing(true);
try {
await client.post("/accessRequests.approve", {
id: notification.accessRequestId,
permission: selectedPermission,
});
notification.accessRequestStatus = "approved";
toast.success(
t(`Permissions for {{ userName }} updated`, {
userName: notification.actor?.name,
})
);
void notification.markAsRead();
} catch {
toast.error(t("Failed to approve access request"));
} finally {
setProcessing(false);
}
}, [notification, processing, selectedPermission, t]);
const handleDismiss = React.useCallback(async () => {
if (processing) {
return;
}
setProcessing(true);
try {
await client.post("/accessRequests.dismiss", {
id: notification.accessRequestId,
});
notification.accessRequestStatus = "dismissed";
toast.success(t("Access request dismissed"));
void notification.markAsRead();
} catch {
toast.error(t("Failed to dismiss access request"));
} finally {
setProcessing(false);
}
}, [notification, processing, t]);
const handleContainerClick = React.useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
return (
<Container gap={8} align="center" onClick={handleContainerClick}>
<Button onClick={handleDismiss} disabled={processing} neutral>
{t("Dismiss")}
</Button>
<SplitButton
options={permissionOptions}
selectedValue={selectedPermission}
onSelect={(value) => setSelectedPermission(value as DocumentPermission)}
onClick={handleApprove}
disabled={processing}
>
{t("Approve")} · {selectedLabel}
</SplitButton>
</Container>
);
}
const Container = styled(Flex)`
margin-top: 8px;
flex-wrap: wrap;
`;
export default observer(AccessRequestActions);
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, hover, truncateMultiline } from "@shared/styles";
import { NotificationEventType } from "@shared/types";
import type Notification from "~/models/Notification";
import useStores from "~/hooks/useStores";
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
@@ -21,6 +22,7 @@ import {
notificationArchive,
} from "~/actions/definitions/notifications";
import { NotificationSection } from "~/actions/sections";
import AccessRequestActions from "./AccessRequestActions";
const CommentEditor = lazyWithRetry(
() => import("~/scenes/Document/components/Comments/CommentEditor")
@@ -37,6 +39,10 @@ function NotificationListItem({ notification, onNavigate }: Props) {
const collectionId = notification.document?.collectionId;
const collection = collectionId ? collections.get(collectionId) : undefined;
const isAccessRequestPending =
notification.event === NotificationEventType.RequestDocumentAccess &&
notification.accessRequestStatus === "pending";
const handleClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
if (event.altKey) {
event.preventDefault();
@@ -86,6 +92,9 @@ function NotificationListItem({ notification, onNavigate }: Props) {
defaultValue={toJS(notification.comment.data)}
/>
)}
{isAccessRequestPending && (
<AccessRequestActions notification={notification} />
)}
</Flex>
{notification.viewedAt ? null : <UnreadBadge />}
</Container>
@@ -117,11 +126,12 @@ const StyledAvatar = styled(Avatar).attrs({
const Container = styled(Flex)<{ $unread: boolean }>`
position: relative;
padding: 8px 12px;
padding-right: 40px;
padding-block: 8px;
padding-inline: 12px 40px;
border-radius: 4px;
${StyledLink}[data-state=open] &,
&:has([data-state="open"]),
&:${hover},
&:active {
background: ${s("listItemHoverBackground")};
@@ -6,6 +6,7 @@ import styled from "styled-components";
import { s, hover } from "@shared/styles";
import Notification, { type NotificationFilter } from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import Empty from "../Empty";
@@ -83,6 +84,7 @@ function Notifications(
) {
const { notifications } = useStores();
const { t } = useTranslation();
const isMobile = useMobile();
const [filter, setFilter] = React.useState<NotificationFilter>("all");
const filterOptions = React.useMemo<Option[]>(
@@ -110,8 +112,9 @@ function Notifications(
<Flex
style={{
width: "100%",
height:
"min(300px, calc(var(--radix-popover-content-available-height) - 44px))",
minHeight: isMobile ? "75vh" : "300px",
maxHeight:
"min(75vh, calc(var(--radix-popover-content-available-height, 75vh) - 44px))",
}}
column
>
@@ -122,7 +125,7 @@ function Notifications(
<HStack>
<StyledInputSelect
label={t("Filter")}
hideLabel
labelHidden
options={filterOptions}
value={filter}
onChange={(value) => setFilter(value as NotificationFilter)}
@@ -1,15 +1,21 @@
import { observer } from "mobx-react";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "~/components/primitives/Drawer";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useConsumeQueryParam from "~/hooks/useConsumeQueryParam";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Notifications = lazyWithRetry(() => import("./Notifications"));
import Notifications from "./Notifications";
type Props = {
children?: React.ReactNode;
@@ -18,8 +24,11 @@ type Props = {
const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const { notifications } = useStores();
const [open, setOpen] = useState(false);
const shouldOpen = useConsumeQueryParam("notifications") === "true";
const [open, setOpen] = useState(shouldOpen);
const isMobile = useMobile();
const scrollableRef = useRef<HTMLDivElement>(null);
const drawerContentRef = useRef<React.ElementRef<typeof DrawerContent>>(null);
useEffect(() => {
void notifications.fetchPage({ archived: false });
@@ -40,6 +49,40 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
}
}, []);
const enablePointerEvents = useCallback(() => {
if (drawerContentRef.current) {
drawerContentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = useCallback(() => {
if (drawerContentRef.current) {
drawerContentRef.current.style.pointerEvents = "none";
}
}, []);
const notificationsList = (
<Notifications onRequestClose={handleRequestClose} ref={scrollableRef} />
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent
ref={drawerContentRef}
aria-label={t("Notifications")}
aria-describedby={undefined}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden>{t("Notifications")}</DrawerTitle>
{notificationsList}
</DrawerContent>
</Drawer>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>{children}</PopoverTrigger>
@@ -51,12 +94,7 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
scrollable={false}
shrink
>
<Suspense fallback={null}>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</Suspense>
{notificationsList}
</PopoverContent>
</Popover>
);
@@ -13,7 +13,7 @@ import Switch from "../Switch";
import EventBoundary from "@shared/components/EventBoundary";
import { InputClientType } from "./InputClientType";
export interface FormData {
export type FormData = {
name: string;
developerName: string;
developerUrl: string;
@@ -22,7 +22,7 @@ export interface FormData {
redirectUris: string[];
published: boolean;
clientType: "confidential" | "public";
}
};
export const OAuthClientForm = observer(function OAuthClientForm_({
handleSubmit,
+17 -2
View File
@@ -6,15 +6,30 @@ import { s } from "@shared/styles";
type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
/** The length of the OTP */
length?: number;
/**
* Whether to accept uppercase letters in addition to digits. Lowercase input
* is normalized to uppercase. Defaults to numeric only.
*/
alphanumeric?: boolean;
};
const sanitizeAlphanumeric = (value: string) =>
value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
export const OneTimePasswordInput = React.forwardRef(
function OneTimePasswordInput_(
{ length = 6, ...rest }: Props,
{ length = 6, alphanumeric, ...rest }: Props,
ref: React.RefObject<HTMLInputElement>
) {
const alphanumericProps = alphanumeric
? {
validationType: "none" as const,
sanitizeValue: sanitizeAlphanumeric,
}
: undefined;
return (
<OneTimePasswordRoot {...rest}>
<OneTimePasswordRoot {...alphanumericProps} {...rest}>
{Array.from({ length }, (_, i) => (
<OneTimePasswordInputField key={i} />
))}
+1 -2
View File
@@ -1,12 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet-async";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { useTeamContext } from "./TeamContext";
type Props = {
title: React.ReactNode;
title: string;
favicon?: string;
};
+3 -1
View File
@@ -7,7 +7,9 @@ import PaginatedList from "~/components/PaginatedList";
type Props = {
documents: Document[];
fetch: (options: any) => Promise<Document[] | undefined>;
// oxlint-disable-next-line no-explicit-any
fetch: (options: Record<string, any>) => Promise<Document[] | undefined>;
// oxlint-disable-next-line no-explicit-any
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: JSX.Element;
+4 -2
View File
@@ -1,4 +1,4 @@
import isEqual from "lodash/isEqual";
import { isEqual } from "es-toolkit/compat";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
@@ -35,10 +35,12 @@ interface Props<
* @param options Pagination and other query options
*/
fetch?: (
// oxlint-disable-next-line no-explicit-any
options: Record<string, any> | undefined
) => Promise<unknown[] | undefined> | undefined;
/** Additional options to pass to the fetch function */
// oxlint-disable-next-line no-explicit-any
options?: Record<string, any>;
/** Optional header content to display above the list */
@@ -78,7 +80,7 @@ interface Props<
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
renderHeading?: (name: React.ReactElement | string) => React.ReactNode;
/**
* Function to determine if an item is a duplicate of the previous item.

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