Compare commits

...

760 Commits

Author SHA1 Message Date
Tom Moor 93c928439d fix: Nested list behavior (#12678)
* fix: preserve nesting when toggling to/from checklist in nested lists

Converting a list to or from a checklist previously cleared all nodes and
re-wrapped them, which flattened any nested list structure. Convert the
list nodes in place instead, recursively mapping list and item types so
nesting is preserved.

https://claude.ai/code/session_01BGH191WyuL9SgQyjfJ3YsD

* feat: convert plain list to checklist when typing checkbox marker

Typing "[ ] " at the start of an existing bullet or ordered list item now
converts the whole list to a checklist, preserving nested list structure,
reusing the in-place list conversion utility.

https://claude.ai/code/session_01BGH191WyuL9SgQyjfJ3YsD

* fix: place cursor at start of item after checklist conversion

After converting a list to a checklist via the checkbox marker input rule,
position the selection at the start of the converted item so the user can
continue typing immediately.

https://claude.ai/code/session_01BGH191WyuL9SgQyjfJ3YsD

* fix: remove vertical margin from checklist nested in a list

A checklist nested inside any list (bullet, ordered, or another checklist)
no longer carries the 1em vertical margin of a top-level checklist. Since
both list_item and checkbox_item render as li elements, target any
checklist-wrapper inside an li rather than only those inside another wrapper.

https://claude.ai/code/session_01BGH191WyuL9SgQyjfJ3YsD

* fix: only highlight the closest list in the formatting toolbar

When the selection sits inside a nested list, the toolbar previously marked
both the inner list and any ancestor list of a different type as active,
because each list button walked the full ancestor chain. Highlight only the
list closest to the selection.

https://claude.ai/code/session_01BGH191WyuL9SgQyjfJ3YsD

* fix: consider node selections on lists in isListActive

When a list node is the target of a NodeSelection, findParentNode resolves
to its parent list rather than the selected node. Check the selected node
directly so the toolbar reflects the selected list.

https://claude.ai/code/session_01BGH191WyuL9SgQyjfJ3YsD

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-13 12:33:17 -04:00
Tom Moor 2292e56834 fix: Comment thread animations (#12677)
* Improve comment thread solidity/animations

* fixes

* Animation flash on thread switch
2026-06-13 12:18:35 -04:00
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
Tom Moor c3c5f148b7 Add Node LTS auto-update script (#11927)
* Add Node LTS auto-update script

* fix: Validate LTS version and update CI step name in Node update workflow

Add semver validation for the fetched LTS version to prevent creating
PRs with invalid node versions (e.g. null) if the upstream API changes.
Also update the human-readable step name in ci.yml during major bumps.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:47:40 -04:00
Copilot 0d0f5cb5c7 Fix Tab key not indenting list items inside toggle blocks (#11914)
* Initial plan

* fix: allow Tab to indent list items inside toggle blocks

When the cursor is inside a list within a toggle block, the indentBlock
command was consuming the Tab key event before the list's sinkListItem
handler could run. This happened because indentBlock found a previous
container_toggle sibling at the ancestor level and returned true.

Fix: return false early in indentBlock when the cursor is inside a list,
allowing the list's Tab handler to handle indentation correctly.

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-01 18:51:35 -04:00
Tom Moor af22ed4d06 fix: Search highlight lag on shared documents (#11926)
Re-highlight result context client-side using the current search query
instead of relying on server-generated <b> tags. This prevents stale
highlights when results from a previous query are still displayed while
a newer search is in-flight. Also guards against setting stale results
by checking the query ref before updating state.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:47:49 -04:00
Copilot 864ec3e24b Fix @mention trigger not firing after CJK characters (#11919)
* Initial plan

* Fix mention trigger to work after CJK characters without preceding space

Agent-Logs-Url: https://github.com/outline/outline/sessions/b34bba3f-fe94-408c-bf09-794f8e3d05ff

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

* Apply suggestion from @Copilot

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 22:35:03 +00:00
Tom Moor db953c8b2f fix: Update Docker GitHub Actions to support Node.js 24 (#11925)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:22:21 -04:00
Copilot c4479e257e chore: upgrade Node.js to 24.14.1 (LTS) (#11918)
* Initial plan

* chore: upgrade Node.js base image from 22.21.0 to 24.14.1 (LTS)

* chore: include node version in node_modules cache keys

* Add canary build for docker changes

* fix: Try docker driver

---------

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-04-01 17:47:01 -04:00
Tom Moor 222de9ef01 fix: Unconnected integrations appearing in settings sidebar (#11913)
* fix: Integrations list missing when language is not English

The group filter on the Integrations settings page compared against the
hardcoded string "Integrations" instead of the translated value from
t("Integrations"), causing no integrations to appear in non-English locales.

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

* fix: Sidebar shows unconnected integrations in non-English locales

Same hardcoded "Integrations" string comparison issue as the main
integrations page — the sidebar filter skipped the connected-check
when the translated group name didn't match the English literal.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 08:12:05 -04:00
Tom Moor 6e95aa441b feat: Add context menus to document breadcrumb items (#11910)
Wrap collection and document names in the header breadcrumb with
ContextMenu components, enabling right-click menus with relevant
actions. Each breadcrumb item type has its own component to scope
hooks. Breadcrumb links prevent navigation when a context menu is open.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 19:44:44 -04:00
Tom Moor b70950627e Preload share popover data on hover (#11909)
* Preload share popover data on hover with useShareDataLoader hook

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

* fix: Route programmatic closes through handleOpenChange and fix race conditions

- closePopover now calls handleOpenChange(false) so reset() fires on all
  close paths, including programmatic closes via onRequestClose
- Reset requestedRef when entity id changes so preload fires for new targets
- Use request counter to prevent stale loading state when reset() is called
  during an in-flight request

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 19:44:37 -04:00
Tom Moor e354db8164 feat: Add support for Docker Swarm style secrets (#11906)
* feat: Add support for Docker Swarm style secrets

* fix: Handle empty-string env values and bare _FILE key in resolveFileSecrets

Use undefined check instead of truthiness so empty-string values are
treated as "already set" and not overridden by _FILE variants. Skip
processing when the key is exactly "_FILE" to avoid creating an
empty-key entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 19:42:20 -04:00
Tom Moor 7f6ec4ae31 fix: Integrations list missing when language is not English (#11908)
The group filter on the Integrations settings page compared against the
hardcoded string "Integrations" instead of the translated value from
t("Integrations"), causing no integrations to appear in non-English locales.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:58:32 -04:00
Tom Moor 701d4bb6ee fix: Present mode slide content not vertically centered (#11901)
* fix: Present mode slide content not vertically centered
2026-03-29 16:42:30 -04:00
Tom Moor 032d5c6b95 fix: Remove archived document from sidebar immediately (#11900)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 16:41:11 -04:00
Tom Moor 33b9a52dfe fix: Empty drafts are not correctly cleared on tab quit (#11899)
dquote> fix: Existing drafts should not focus the editor
2026-03-29 10:36:43 -04:00
Tom Moor 4b16545b10 Fix Comment.toPlainText using wrong schema for mention nodes (#11889)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:51:05 -04:00
Copilot 27dc02aad1 Add anchor text to MCP comment tool responses (#11886)
* Initial plan

* Add comment anchor text to MCP comment tool responses

Agent-Logs-Url: https://github.com/outline/outline/sessions/294b6510-996f-4a86-a7d6-7ed1c336fc19

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

* Address PR review: fix auth gap, cache marks, add anchorText tests

- Always authorize read access in update_comment before exposing anchor text
- Cache comment marks per document in list_comments to avoid O(n * docSize)
- Add 4 MCP tests verifying anchorText presence/absence in responses

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:08:34 -04:00
Copilot df5dd0b98d Fix custom team logo not appearing in link previews for public shares (#11872)
* Initial plan

* fix: resolve team avatar to signed URL for public share link previews

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Agent-Logs-Url: https://github.com/outline/outline/sessions/3632734e-1bb5-4705-bdcd-a2ccbb211af8

* refactor: move avatar URL resolution to Team.publicAvatarUrl()

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Agent-Logs-Url: https://github.com/outline/outline/sessions/a2191be3-0533-459a-8366-602bb798a60e

* test: add Team.publicAvatarUrl model tests; update JSDoc

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Agent-Logs-Url: https://github.com/outline/outline/sessions/7609501c-a4d1-44ea-a7bf-fa6fd8e7c999

* test: fix Team.publicAvatarUrl tests to use actual attachment URLs

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Agent-Logs-Url: https://github.com/outline/outline/sessions/0a768f8b-0dd8-4e7a-a50d-873af58aab28

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-27 19:55:15 -04:00
Copilot 3cc85f1cdf Fix DocumentMove dialog hiding siblings and nieces/nephews as move targets (#11885)
* Initial plan

* fix: show siblings and descendants in DocumentMove dialog

The filterSourceDocument function was incorrectly removing the document's
parent node from the navigation tree, which also hid all siblings (children
of the same parent) and their descendants.

Instead, only the document itself and its own descendants are now excluded
(to prevent circular references). The parent is kept in the tree so siblings
remain visible as valid move targets.

Agent-Logs-Url: https://github.com/outline/outline/sessions/12574f1c-7a7c-45a0-8444-19e24aa10782

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-27 17:05:21 -04:00
Tom Moor 0b213bd6b8 feat: Map document creator to existing users during JSON import (#11879)
* feat: Map creator/updater IDs to existing users during JSON import

When importing documents from JSON, resolve the original document author
to an internal user by matching on user ID first, then email, falling
back to the importing user. Results are cached to avoid redundant queries.

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

* fix: Add negative caching for user resolution during import

Cache misses (not just hits) in resolveUserId so that repeated lookups
for users that don't exist in the target team are served from cache
instead of hitting the database for every document.

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

* docs: Fix resolveUserId JSDoc to match actual behavior

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 08:42:32 -04:00
Tom Moor c91b839d22 fix: Unable to resize imported image from docx (#11878)
* fix: Mammoth converts docx images to <img> tags with base64 data URIs but no width/height attributes

* fix: Bound memory usage and prevent infinite loop in image dimension parsing

Decode only a 64 KB prefix of base64 data URIs instead of the full payload,
cap the JPEG marker scan at 64 KB, and bail on malformed segment lengths
(< 2 or overflowing the buffer) to prevent an infinite loop on truncated data.

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-03-27 08:42:24 -04:00
Tom Moor 45b2f6e222 fix: read-only scoped API keys cannot access MCP (#11875) 2026-03-26 00:15:28 -04:00
Tom Moor b91d9e9a72 feat: Extract search into pluggable provider system (#11448)
* feat: Extract search into pluggable provider system

Refactors the monolithic SearchHelper into a pluggable search provider
architecture, enabling alternative search backends (Elasticsearch,
Turbopuffer, etc.) while preserving PostgreSQL full-text search as the
default. The SEARCH_PROVIDER env var selects the active provider.

- Add BaseSearchProvider abstract class and SearchProviderManager
- Add Hook.SearchProvider to the plugin system
- Move PostgreSQL search logic into plugins/postgres-search/
- Add SearchIndexProcessor for event-driven index sync
- Update all callers to use the provider manager directly
- Keep SearchHelper as a deprecated thin wrapper for backwards compat

Closes #11347

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

* refactor: Remove deprecated SearchHelper wrapper

All callers now use SearchProviderManager directly, so the thin
delegation wrapper is no longer needed.

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

* refactor: Rename postgres-search plugin to search-postgres

Renames the plugin folder and id so that future search provider plugins
(e.g. search-elasticsearch, search-turbopuffer) will be colocated
alphabetically.

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

* refactor: Remove special-case plugin import from SearchProviderManager

Make PluginManager.loadPlugins resilient to individual plugin load
failures so SearchProviderManager can use the standard getHooks path
without needing to directly import the search-postgres plugin.

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

* test: Add missing search provider tests for full coverage parity

Adds all tests that existed in the old SearchHelper.test.ts but were missing
from PostgresSearchProvider.test.ts, including searchTitlesForUser status
filters, collection filtering, group memberships, and sorting tests.

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

* feedback

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:01:26 -04:00
Tom Moor 979d9a412d Mermaid improvements (#11874)
* fix: Upgrade mermaid to 11.13.0

Includes a fix for incorrect viewBox casing in Radar and Packet diagram
renderers (mermaid-js/mermaid#7076) and other improvements.

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

* fix: Use visibility:hidden for mermaid rendering element

Instead of positioning the temporary render element offscreen at
-9999px, use visibility:hidden with position:fixed so the browser
computes correct bounding boxes for SVG elements. Offscreen elements
can produce incorrect getBBox() results, leading to wrong viewBox
dimensions and diagrams rendering too big or too small.

Fixes #11782

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

* Add session storage for generated diagrams to reduce relayout

* fix: Use LRU eviction for mermaid sessionStorage cache

Track access order via a dedicated LRU index key so the cache evicts
least-recently-used entries rather than arbitrary ones.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 22:59:57 -04:00
Tom Moor c2ccdb6fd4 fix: Prevent registration of duplicate passkeys on the same device (#11870)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:54:43 -04:00
Tom Moor 793804cd0d feat: Strip comments from presentation mode (#11860)
* feat: Strip comment marks from documents in presentation mode

Move removeMarks to shared ProsemirrorHelper and use it to strip comment
marks before rendering slides. Make server ProsemirrorHelper extend the
shared class to eliminate duplication and remove SharedProsemirrorHelper
imports from server code.

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

* fix: Use Set for mark lookup and cloneDeep for browser compat

Use a Set for O(1) mark lookups in removeMarks traversal. Replace
structuredClone with lodash/cloneDeep to support older browsers
that lack the native API.

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

* test: Add tests for ProsemirrorHelper.removeMarks

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 20:21:43 -04:00
Copilot f1e5a7cfa7 Fix passkey login 400 error when authenticatorAttachment is undefined (#11856)
* Initial plan

* Fix passkey login 400 error when authenticatorAttachment is undefined

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Agent-Logs-Url: https://github.com/outline/outline/sessions/b7ea5777-cd06-41e7-a796-70ea083dfc34

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-23 18:54:13 -04:00
Tom Moor 84aed78ee2 fix: Improve performance when editing titles in large open document trees (#11858) 2026-03-23 18:53:37 -04:00
Tom Moor 33d8e41e41 fix: Sub-table header sticky behavior (#11857) 2026-03-23 18:53:34 -04:00
Tom Moor 7dc1d12d3b feat: Support simplified mention syntax in markdown for MCP (#11851)
* feat: Support simplified mention syntax in markdown for MCP clients

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

* Restore translations

* PR feedback

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 08:08:24 -04:00
Apoorv Mishra 0e978e1e34 feat: highlight commented images (#11808) 2026-03-22 22:19:48 -04:00
Tom Moor 0390f30e1d Restore enterprise translations 2026-03-22 21:56:11 -04:00
Tom Moor 4a40712dcc fix: Improve shared command bar search results and add recent docs (#11849)
Show all search results by passing keywords to Fuse.js, display search
context as subtitle, track recently viewed documents for empty state,
and move SharedSearchActions outside KBarPortal to prevent mount flicker.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 18:49:43 -04:00
Tom Moor 0ba310e027 Remove unused files and dependencies (#11850)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 18:44:51 -04:00
Tom Moor eda59b1450 feat: Show group members popover in share search suggestions (#11848) 2026-03-22 13:42:58 -04:00
Tom Moor ac1f68a447 Escape key clears search highlights in documents (#11847)
When navigating to a document from search results, the search term is
highlighted via FindAndReplace but the popover is not open, so there was
no way to dismiss the highlights. This adds an Escape key binding to the
FindAndReplace extension that clears highlights when active.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 12:21:34 -04:00
Tom Moor 5691ea5ae3 fix: Prevent comment sidebar from opening unexpectedly (#11845)
* fix: Prevent comment sidebar from opening unexpectedly

Guard against stale cross-document focused comments opening the sidebar
by checking the comment's documentId matches the current document. Also
stop restoring rightSidebar state from localStorage on app load so the
sidebar always starts closed.

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

* fix: Restore rightSidebar persistence on page reload

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 12:10:15 -04:00
Tom Moor 8f541eb321 feat: Add command bar search to public shares (#11846)
Replace the SearchPopover in the shared sidebar with a command bar that
opens via Cmd+K or a search button. Search results are scoped to the
share and navigate to shared document paths with highlight support.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 12:09:58 -04:00
Tom Moor c0a6bc911c Add create_attachment tool to get a presigned POST for file upload (#11823)
* fix: Data always included in list_documents response

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

* type -> resource

* Add URL resolving

* Add create_attachment tool to get a presigned POST for file upload bypassing context
2026-03-22 10:49:51 -04:00
Tom Moor fddf630e49 Add configurable MCP workspace guidance (#11839)
* Add configurable MCP workspace guidance

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

* fix Instructions passing, tweak UI

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:45:09 -04:00
Tom Moor a4badbea9c feat: Role preference for collection template mangement (#11821)
* wip

* ui

* test

* Apply suggestion from @Copilot

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

---------

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

* fix: Missing translations

* fix: Missing translations

* welcome

* Apply suggestions from code review

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

* translations

---------

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

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

* type -> resource

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

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

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

---------

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

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

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

* Support GitLab work_items URL structure in parseUrl

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

---------

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

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

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

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

---------

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

* fix: Handle SMTP_FROM_EMAIL/SMTP_REPLY_EMAIL in mailbox format

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

---------

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

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

* Optimised images with calibre/image-actions

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Add GitLab Project unfurl support

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

* Fix TypeScript errors: add explicit return type to parseUrl

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

* tweaks

* progress

* Remove log noise

---------

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

* Add GitHub Project V2 unfurl support to the GitHub plugin

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

* Various fixes

---------

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

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

* Add Project type and unfurl implementation for Linear projects

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

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

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

* Make actor parameter optional in unfurl helper methods

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

* fix: Resolve type errors in Linear project unfurl

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

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

* Determine mention type

* styling

---------

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

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

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

---------

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

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

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

* Preserve PDF preview for block menu option and attachment replacement

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

---------

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

* Add PDF preview toggle button to attachment formatting toolbar

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

* Tweak icon

---------

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

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

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

---------

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

* fix: BacklinksProcessor does not check teamId

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

* Add Open in Desktop option to document menu

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

* Tweak naming

---------

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

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

* Include commit sha in x-client-version header

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

* perf: Removes count query for client requests

---------

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

* Add ABAP as a supported code formatting language

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

---------

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

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

* Implement FontAwesome icon support for Mermaid diagrams

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

---------

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

* fix: preserve table row background colors when duplicating documents

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

* test

---------

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

* chore: use filtered fetch in Figma and Linear plugins

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

---------

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

* Restore bounce anim

* fix: Clear query on button open

* Sub-menu support

* fix bugs

* PR feedback

* Insert templates from block menu

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

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

* fix scaling, query string, icons, refactor

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

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

* Restore Pako transient dep types

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

* Restore bounce anim

* fix: Clear query on button open

* Sub-menu support

* fix bugs

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

* Remove star control on DocumentListItem on mobile

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

---------

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

closes #11656

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* stop duplicating permissions

* add tests

* fix comment

* filter duplicates

* filter duplicates

* minor fixes

* fix child document permissions not being recalculated

* expand tests

* Update DocumentMovedProcessor.test.ts

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

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

* requested changes

* remove all sourced permissions before calculating new ones

* remove all sourced permissions before recalculating

* Add additional tests

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-05 08:22:03 -05:00
Apoorv Mishra e06ff9d7d2 Make history drawer on mobile usable again (#11649)
* fix: history drawer height

* fix: history drawer content overflow

* fix: comment
2026-03-05 08:13:09 -05:00
Copilot 89cfc76d8f Remove artificial statement timeout from popularity score calculation (#11652)
* Initial plan

* fix: remove transaction wrapper and artificial statement timeout from calculateScoresForDocuments

The method was wrapping a read-only CTE query in a transaction solely to
SET LOCAL statement_timeout = '30000ms'. On instances with large tables this
30-second cap caused SequelizeDatabaseError: canceling statement due to
statement timeout. Removed the unnecessary transaction and the STATEMENT_TIMEOUT_MS
constant so the query runs unimpeded under the database server's own timeout policy.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-05 08:11:48 -05:00
Copilot c22ba4fa0c Validate oauthClientId as UUID before database query (#11653)
* Initial plan

* Fix SequelizeDatabaseError by adding UUID validation to oauthClientId

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-05 08:01:19 -05:00
Tom Moor 8dabc7f3cf Enable the rate limiter by default 2026-03-04 20:39:52 -05:00
Tom Moor bd7f0cdc12 Adding framer-motion to the vendor chunk config consolidates all framer-motion code into a single eagerly-loaded chunk, ensuring featureNames is computed exactly once before any mutation happens. (#11643) 2026-03-04 18:23:40 -05:00
Tom Moor 9a849418b1 fix: Upgrade framer-motion to v6, fix tab animation (#11637)
* Revert "Revert "fix: Upgrade framer-motion to v5, fix tab animation (#11632)"…"

This reverts commit 2c7ec179fa.

* fix: Race condition
2026-03-04 17:35:18 -05:00
Copilot 0ab3a962d9 Skip unfurl attempts for non-HTTP(S) URL schemes (#11640)
* Initial plan

* Fix: Skip unfurling for non-http(s) and non-mention URL schemes

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-04 09:12:13 -05:00
Tom Moor 2c7ec179fa Revert "fix: Upgrade framer-motion to v5, fix tab animation (#11632)" (#11636)
This reverts commit 526833ec6e.
2026-03-04 07:14:25 -05:00
Tom Moor 526833ec6e fix: Upgrade framer-motion to v5, fix tab animation (#11632) 2026-03-03 21:18:09 -05:00
Tom Moor d8f39e3b67 fix: Sidebar does not appear in some situations 2026-03-03 19:54:46 -05:00
Tom Moor 0565616b02 fix: Add skip error for multi-source Notion databases 2026-03-03 19:32:33 -05:00
Tom Moor dd9b28e898 feat: Add async loading ELK layout engine (#11631) 2026-03-03 19:29:46 -05:00
Tom Moor f5ef2f2b30 perf: Add request deduping in ApiClient (#11629) 2026-03-03 18:06:48 -05:00
Tom Moor 1f251d4829 perf: Include collection updatedAt property in websocket payloads (#11628) 2026-03-03 18:06:41 -05:00
Tom Moor 749bf49ebe fix: Avatar upload (#11624)
* fix: Avatar upload

* Restore internal public ACL
2026-03-03 08:06:17 -05:00
Tom Moor 371665b35c fix: Cannot access attribute text on document omitted from attributes 2026-03-02 22:09:55 -05:00
Tom Moor d20779e7ea fix: Settings layout tweaks 2026-03-02 19:12:49 -05:00
Tom Moor 1d86a4aee4 Add limit of 10 attempts for OTP login (#11623)
* Add limit of 10 attempts for OTP login

* test
2026-03-02 18:41:03 -05:00
Copilot 19e5888216 fix: Preserve diagram file format (PNG/SVG) when editing (#11622)
* Initial plan

* Implement format retention for diagram editing

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

* Address code review feedback: improve comments and property order

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

* refactor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-03-02 18:12:52 -05:00
Apoorv Mishra a4039ccc81 fix: update url when moving away from history sidebar (#11618) 2026-03-02 17:51:43 -05:00
Tom Moor f0a7ee6df8 chore: Update rate limiter to use a combination of ID and IP (#11613)
* chore: Update rate limiter to combo ID and IP

* test
2026-03-02 17:47:16 -05:00
Tom Moor c428d551b8 perf: Check socket is still connected before querying db (#11620) 2026-03-02 17:47:06 -05:00
Tom Moor 904f1a9726 tsc 2026-03-02 07:21:23 -05:00
Tom Moor 8dc4f8b422 feat: Add text wrap option for code blocks (#11614) 2026-03-02 07:08:12 -05:00
Tom Moor 1ceb476a04 fix: Public docs not visible 2026-03-02 07:06:39 -05:00
Tom Moor 5439afa5c8 fix: Minor language and position tweaks 2026-03-01 20:12:52 -05:00
Tom Moor 8619b219e7 feat: Configurable slash embeds (#11612)
* wip

* Use id instead of title
Settings UI tweaks

* test

* Add toggle for all providers

* Remove 'Abstract' embed, no longer available
2026-03-01 17:47:29 -05:00
Copilot 5a14944d0c Remove starred document when actor archives it (#11611)
* Initial plan

* Add DocumentArchivedProcessor to remove archived documents from actor's stars

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

* Fix unused variable in test

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

* Remove unnecessary transaction wrapper for single operation

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

* Add JSDoc documentation to perform method

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

* Address code review: add @throws annotation and fix spacing

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

* Fix TypeScript errors in test file - add non-null assertions for collectionId

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

* Use ctx method for events

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-03-01 17:47:17 -05:00
Tom Moor 93509564e0 Update template management policies (#11608)
* Template management policies

* PR feedback

* fix: Should not be able to navigate to template edit screen
Remove unused action
Add 'New document from template' to template menu
2026-03-01 09:48:17 -05:00
Tom Moor aacad48585 fix: Flashing of sidebar on initial load (#11607)
* fix: Flashing of sidebar on initial load

* refactor

* refactor

* Remove toggleComments

* fix: Skip animation on page load

* feedback
2026-02-28 21:54:16 -05:00
Tom Moor 8dbbcb6dce perf: Variety of perf fixes in ProsemirrorHelper (#11554)
* perf: Variety of perf fixes in ProsemirrorHelper

* doc

* feedback
2026-02-28 14:12:13 -05:00
Tom Moor 597da9f1ee fix: Search sorting wraps onto its own line (#11606)
* fix: Sort wrapping on search

* styling
2026-02-28 13:39:40 -05:00
Tom Moor eab57a7144 fix: Mermaid diagram (#11604)
* fix: New mermaid diagram should auto-edit
* fix: Empty mermaid diagram should not show zoom control
* fix node selection
2026-02-28 12:47:37 -05:00
Tom Moor 20f928332e fix: Resolve jsx-key and no-this-alias lint warnings
Move key props before spreads, add missing keys to iterator fragments,
and replace this-destructuring with direct property access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:20:48 -05:00
Apoorv Mishra 12847d3d79 Fix: Right sidebar header misaligned (#11539)
* fix: right sidebar header misaligned

* fix: detect if vertical window scrollbar is visible

* fix: detect appearance of scrollbar using ResizeObserver

* Update Right.tsx

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-28 14:02:56 +00:00
Copilot b298456126 Increase request timeout for files.create to support large file uploads (#11570)
* Initial plan

* Add 30-minute timeout for files.create endpoint to handle large file uploads

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-28 09:02:28 -05:00
Tom Moor 4e773d7cb0 fix: Notifications popover (#11602)
* fix: Stable ordering of notification items

* fix: Editor in notification list items capturing clicks
2026-02-28 08:53:28 -05:00
Tom Moor 0dfab5b245 perf: Raise Notion pageSize 25 -> 100 2026-02-28 08:52:23 -05:00
Tom Moor a40ab4867d Improved search popover on public docs (#11601)
* stash

* refactor
2026-02-27 22:44:31 -05:00
Tom Moor 3270e05abc fix: Unneccessary hover state on sidebar 2026-02-27 21:02:01 -05:00
Tom Moor 36f5ce26e2 fix: Search rank ordering (#11599)
* fix: Search ranking

* test
2026-02-27 20:43:56 -05:00
Tom Moor 10303220a5 Public docs search should not include internal popularity score (#11598) 2026-02-27 20:25:50 -05:00
Copilot 1c52a3e7da Fix custom emoji UUID escaping in API document creation (#11594)
* Initial plan

* Add custom emoji UUID parsing support

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

* Fix custom emoji UUID parsing - working solution

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

* Add JSDoc and improve code quality based on review

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

* Add JSON content verification to custom emoji test

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-27 19:32:06 -05:00
Tom Moor 8a7501d0ad chore: Upgrade js-yaml (#11597) 2026-02-27 19:31:55 -05:00
Ashley Sommer 8048b7e530 feat: Add support for configurable proxy IP header in environment settings (#11595)
* feat: Add support for configurable proxy IP header in environment settings

* Update server/env.ts

Remove mention of Koa from docs

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update .env.sample

Remove mention of Koa from env sample.

Co-authored-by: Tom Moor <tom.moor@gmail.com>

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2026-02-27 19:27:24 -05:00
Tom Moor d54167dbdf perf: Presenting lists of imported documents causes database lockup (#11591) 2026-02-27 08:35:11 -05:00
Tom Moor 6b98da54bd fix: Collection overview edit state (#11586) 2026-02-26 18:03:10 -05:00
Tom Moor 03e322f1ad fix: Flashing empty state in mention menu (#11587) 2026-02-26 18:02:19 -05:00
Tom Moor e876b2131e fix: Checkbox list completed items toggle does not take into account nested lists (#11583) 2026-02-26 15:50:12 -05:00
Apoorv Mishra 1e62c09dd0 fix: email horizontal overflow (#11584) 2026-02-26 15:50:04 -05:00
Tom Moor 0f23b63e35 perf: Remove unnecessary complex joins (#11581)
* perf: Remove unneccessary complex joins

* Further reduce joins and data loading
2026-02-26 09:11:37 -05:00
Tom Moor 658be50dcd Move 'Failed to unfurl' error -> warning 2026-02-26 09:10:59 -05:00
Tom Moor 9288ac87e0 fix: Webhook held in memory after timeout (#11580) 2026-02-26 08:52:40 -05:00
Tom Moor 6aa7f34b01 perf: Hold 10s cache on user.collectionIds (#11579) 2026-02-26 08:15:35 -05:00
Apoorv Mishra 7999cf0b98 fix: prevent horizontal overflow for toggle block content (#11577) 2026-02-26 06:51:42 -05:00
Tom Moor 4ebb0e1790 fix: Missing /mcp well-known routes (#11575) 2026-02-25 16:47:06 -05:00
Tom Moor 0ce992e87a fix: Misuse of transactions in revision endpoints (#11574) 2026-02-25 16:44:10 -05:00
Tom Moor 3c3d18637e fix: Ignored child page mentions in Notion importer (#11567) 2026-02-25 16:28:48 -05:00
Copilot 6708f5dbc2 Add dedicated tracing for MCP resources (#11569)
* Initial plan

* feat: Add separate tracing for MCP resources

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-25 14:40:08 -05:00
Tom Moor 38920cd1fe fix: Read only guard on table cell selection styling (#11555)
closes #11530
2026-02-25 07:35:19 -05:00
Tom Moor 053133f2b7 perf/separate-query (#11553) 2026-02-24 22:59:57 -05:00
Tom Moor 505e41661a fix: Include text in revisions payload as documented 2026-02-24 21:53:59 -05:00
Tom Moor 3c4ed666ce fix: Collection overview not appearing on public share 2026-02-24 21:36:32 -05:00
Copilot 25e222bb22 Fix print error caused by queueMicrotask timing with React (#11551)
* Initial plan

* Fix print error by replacing queueMicrotask with setTimeout

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-24 20:05:32 -05:00
Tom Moor 7c84c67077 perf: slice before map 2026-02-24 19:31:59 -05:00
Tom Moor d3fd0fc537 Revert "chore: Move to findAndCountAll instead of separate queries (#11536)" (#11550)
This reverts commit c68c6af8f4.
2026-02-24 19:30:23 -05:00
Copilot 7f88ab55fb Handle network failures in installation.info endpoint for isolated environments (#11546)
* Initial plan

* Add error handling for Installation.info endpoint in isolated environments

* Return -1 for versionsBehind when network request fails

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-24 15:20:48 -05:00
Tom Moor 9038db525e fix: Split comment mark is not correctly updated/deleted (#11537)
* fix: Split comment mark is not correctly updated/deleted

* fix: Mark order matters, comment and link should span around other marks

* fixes:
1. underline spans all descendants of comment-marker span
2. override background of all descendants of comment-marker span, upon
   hover & focus

---------

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>
2026-02-24 08:40:05 -05:00
Tom Moor 9c7a53102b chore: Ensure ip address is passed through mcp events (#11542) 2026-02-24 08:31:25 -05:00
huiseo 7dc00b7d31 fix: prevent duplicate Korean IME character on Enter in search (#11543)
When typing Korean with IME on Mac and pressing Enter, the last
composed character was duplicated due to the SearchInput remounting
during active composition. Added isComposing check to skip keydown
handling during IME composition, consistent with other input components.
2026-02-24 08:31:13 -05:00
Tom Moor 112731a52b fix: Incorrect spread in DocumentImportTask
closes #11538
2026-02-23 23:50:45 -05:00
Tom Moor c68c6af8f4 chore: Move to findAndCountAll instead of separate queries (#11536)
* perf: Move to findAndCountAll instead of separate queries

* perf: slice before map
2026-02-23 22:48:07 -05:00
Tom Moor 957ce69d2e perf: Move image download out of transaction (#11528)
* perf: Move image download out of transaction

* loop
2026-02-23 22:14:03 -05:00
Tom Moor ff6a20ef38 perf: Slow query in NotificationHelper (#11534)
* perf: Slow query in NotificationHelper

* Create index concurrently
2026-02-23 21:31:52 -05:00
Tom Moor 6a69990833 Attribute MCP mutations separately in audit log events (#11533) 2026-02-23 21:31:34 -05:00
Tom Moor 0f45778d79 perf: Protect against ValidateSSOAccessTask thundering herd (#11532)
* perf: Protect against many tabs reloading at once

* PR feedback
2026-02-23 21:05:01 -05:00
Tom Moor 3886c179c5 Remove default user scope on GroupUser (#11531)
* Remove default user scope on GroupUser

* revert
2026-02-23 20:24:22 -05:00
Tom Moor ccde98ce82 fix: Handle template delete and error cases 2026-02-22 16:54:14 -05:00
Tom Moor 86a106e8e9 perf: Improved vendor chunking (#11518)
* perf/improve-vendor-chunking

* Enable bundle-size run on config change

* fix: Modules that should be lazy loaded

* fix: Mermaid in initial chunk

* tsc

* test

* Defer refractor core loading

* test

* test

* remove vendor chunk

* fix: prosemirror dragged into initial chunk
2026-02-22 16:25:00 -05:00
Tom Moor 3fe5f907db fix: Add CORS header to locale file route
Allow cross-origin requests for locale JSON files, consistent with
other static asset routes. Needed when loading translations via CDN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 15:30:17 -05:00
Tom Moor 496b89c7f8 chore: Remove gitbeaker dep on client (#11517)
Add dupe detection to gitlab install
2026-02-22 00:38:10 -05:00
Tom Moor 46dd13fc7f Update integrations directory to color icons (#11516) 2026-02-22 02:37:04 +00:00
Tom Moor ac29295dd2 fix: Tighten touch device detection (#11515) 2026-02-21 18:27:00 -05:00
Apoorv Mishra 0e8fde3bb1 Perf: apply initial decorations early on for toggle blocks (#11493)
* draft

* Revert "draft"

This reverts commit 911c2996be.

* fix: that simple, huh
2026-02-21 18:26:52 -05:00
Salihu cad670f19c feat: GitLab integration (#10861)
Co-authored-by: Tom Moor <tom@getoutline.com>
closes #6795
2026-02-21 17:52:27 -05:00
Tom Moor 00ef17b913 fix: Flaky i18n test (#11514) 2026-02-21 12:35:01 -05:00
Tom Moor 05381ff101 Add move_document tool (#11510) 2026-02-20 21:39:14 -05:00
Tom Moor 519fd024f9 Add Datadog tracing to MCP tool handlers (#11509)
Wraps all MCP tool and resource handlers with Datadog APM spans so that
each invocation is visible in traces under the `outline-mcp` service.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:08:46 -05:00
Tom Moor 7be893f9a3 Refactor templates (#11027)
closes #8674
2026-02-20 18:53:00 -05:00
Tom Moor 52448714d9 fix: documents.import no longer allows direct upload (#11506) 2026-02-20 12:18:22 -05:00
Tom Moor 6e92313f73 Refactor ActionContextProvider to allow more model coverage (#11503) 2026-02-19 23:10:13 -05:00
Tom Moor dfd969084b fix: Sticky header transparent background (#11501)
fix: Custom header color incorrect text color
2026-02-19 21:15:07 -05:00
Tom Moor 758d2b62f5 fix: Overly greedy background -> highlight (#11500) 2026-02-20 01:44:39 +00:00
Tom Moor b90ff98cef fix: Read-only collection editor does not remount correctly on nav (#11499) 2026-02-20 01:28:54 +00:00
Tom Moor 23642fbd85 fix: Ignore browser cache for diagram extension (#11498)
closes #11496
2026-02-19 20:03:30 -05:00
Tom Moor 3fa429977a fix: Find and replace option immediately closes when opening on mobile (#11497)
* fix: Index out of bounds

* fix: Find and replace auto dismissal

* fix: Lost focus after vaul close
2026-02-19 19:18:03 -05:00
Copilot 8ddebb920e Add child documents list to markdown export for shared documents (#11495)
* Initial plan

* Add child documents list to markdown export for shared documents

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

* Apply prettier formatting to app.ts

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

* fix tree context

* test: Account for document share

---------

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-02-19 18:45:13 -05:00
Tom Moor 7ff6f1defb feat: Add webhooks for file attachments (#11494) 2026-02-19 17:28:50 -05:00
Tom Moor f2016bb1ca fix: Pagination on search (#11489) 2026-02-19 17:28:35 -05:00
Tom Moor ba5e4dddbc Add missing shortcut (#11492) 2026-02-19 17:28:19 -05:00
Tom Moor bb8f73cb8d fix: Default scopes not provided in OAuthAuthorize 2026-02-18 22:19:05 -05:00
Tom Moor 4aeea4f73c mcp: Add draft and publish (#11488)
* mcp: Add draft and publish

* refactor

* touch
2026-02-18 21:23:27 -05:00
Copilot 2e0bc66ad1 Fix React Doctor error-level issues (#11483)
* Initial plan

* Fix React Doctor errors: aria-selected, key props, alt attributes, layout animation, nested component, reduced motion

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

* Fix remaining React Doctor errors: refactor useTrackLastVisitedPath to avoid useEffect

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

* Revert useMeasure change

---------

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-02-18 19:47:56 -05:00
Tom Moor c4d861e0ae fix: Overflowing long redirect url (#11486) 2026-02-19 00:39:22 +00:00
Apoorv Mishra f02520444e Toggle toggle block's state upon clicking its head (#11469)
* fix: toggle block upon clicking toggle head in read-only mode

* fix: show pointer

* fix: prevent default

* fix: simplify
2026-02-17 18:59:17 -05:00
Tom Moor 6695ae1f3e Reduce popularity boost in search results (#11481) 2026-02-17 18:51:46 -05:00
Tom Moor 924db0a3fd fix: Handle invalid claudeai scope (#11484)
* fix: Handle invalid 'claudeai' scope

* Add docs link
2026-02-17 18:51:36 -05:00
Tom Moor c9fe7b3d5c Ensure full urls are returned from MCP (#11482)
* Ensure full urls are returned from MCP

towards #11474

* Fix pathToUrl using path.join for URLs and add test coverage

path.join collapsed https:// to https:/ — use URL constructor instead.
Added assertions verifying full URLs are returned for collections and
documents across list, create, update, and resource endpoints.

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

* Add scopes_supported

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:05:43 -05:00
Tom Moor 1937043aed feat: MCP Server (#11464)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:14:18 -05:00
Tom Moor 957648a588 feat: OAuth dynamic client registration (#11462)
* feat: DCR first pass

* Add cleanup task, management endpoints

* Apply suggestions from code review

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

* wip

* Combine migrations

* Self review

* fix: Guard OAuth policies

* fix: Application access list not updating on deletion

* feat: Add OAUTH_DISABLE_DCR env var to disable dynamic client registration

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

* fix: Validate max length of redirect URIs in DCR schemas

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

* Self review

* Use withCtx methods for correct event creation

* Remove incorrect scopes_supported

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:30:19 -05:00
Copilot 5c01909909 Add Fortran language support to code blocks (#11471)
* Initial plan

* Add Fortran language support to code blocks

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-16 10:49:24 -05:00
Tom Moor 84d6ed01e3 chore: Remove 'Features' settings 2026-02-16 10:31:47 -05:00
Tom Moor c758f0d93a chore: Upgrade Zod to version 4 (#11465) 2026-02-15 22:54:50 -05:00
Tom Moor c54194f97a fix: Unobserved components (#11460)
* fix: Unobserved components

* mas

* More missing observers
2026-02-15 15:14:53 -05:00
Tom Moor a860cfc9ec v1.5.0 2026-02-15 12:54:42 -05:00
Tom Moor 08d58f7a6d fix: Small race conditions in diagrams.net integration (#11458) 2026-02-15 12:45:40 -05:00
Tom Moor 45a19d52cf Update documents.import to accept attachmentId (#11457)
* Update documents.import to accept attachmentId

* Add toast
2026-02-15 11:47:08 -05:00
Tom Moor de69a4e671 chore: Cleanup collection create dialog (#11454) 2026-02-14 17:39:53 -05:00
Tom Moor 7824f6b363 feat: Allow creating new doc before/after (#11453) 2026-02-14 17:24:52 -05:00
Tom Moor f6709520fa Add missing tooltips (#11452) 2026-02-14 21:44:45 +00:00
Tom Moor 66b0341cfa fix: Synthetic 'latest' revision fails to load (#11451)
closes #11449
2026-02-14 16:09:10 -05:00
Copilot 057d57e21a Add alphabetic ordered list support to markdown parser (#11446)
* Initial plan

* Add alpha list markdown parsing support

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

* Add integration tests for alpha list parsing and serialization

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

* Address code review feedback - improve marker matching logic

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

* Add explanatory comment for line offset constant

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-14 16:08:58 -05:00
Tom Moor 13c00c4663 chore: Convert rtl prop to transient, addresses warnings (#11450) 2026-02-14 15:53:06 -05:00
Tom Moor eb584ed6b6 perf: Load translation locale files over CDN URL (#11445)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:34:08 +00:00
Tom Moor 40c81a5e30 fix: Notification badge does not appear until notification popover opened (#11444) 2026-02-14 10:01:19 -05:00
Translate-O-Tron 5e976fe732 New Crowdin updates (#11380)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Korean translations from Crowdin [ci skip]
2026-02-14 09:19:45 -05:00
Tom Moor fe9daa0a75 fix: Collections with the same name overwrite in export (#11443) 2026-02-14 09:19:30 -05:00
Tom Moor 08227ce4da fix/edit-redirect (#11442) 2026-02-14 13:40:27 +00:00
Tom Moor 4f6ee1a00b feat: Add a preference for desktop notification badge off/count/indicator (#11436) 2026-02-13 18:04:10 -05:00
Tom Moor 797c28a12e fix: Edits that only include a mention below edit distance do not trigger mention (#11434) 2026-02-13 18:02:47 -05:00
Salihu 129e872578 filter group members (#11403)
* filter group members

* requested changes
2026-02-13 17:35:54 -05:00
Copilot b4053f344f Add Alt-click to recursively expand/collapse sidebar documents and collections (#11432)
* Initial plan

* Add alt-click to expand/collapse all nested documents

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

* Fix callback stability to prevent unnecessary re-renders

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

* Add alt-click expand/collapse support for CollectionLink

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

* refactor

* Add support for other link types

* Handle unloaded

* refactor

* refactor

---------

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-02-13 17:27:20 -05:00
Tom Moor ffe7cda26b fix: Mispositioned toolbar on first document open (#11437)
closes #11423
2026-02-13 17:13:48 -05:00
Tom Moor 38880f8335 fix: Missing check for disabled group mentions (#11435) 2026-02-13 08:06:24 -05:00
Tom Moor 1caca05876 fix: No longer use public acl for avatars (#11427)
Related #11367
2026-02-12 21:56:21 -05:00
Tom Moor 0722b42613 fix: Potential task queue saturation in Notion importer (#11428)
* fix: Potential task queue saturation in Notion import

* Reduces concurrent Notion API pressure from 3× the recursive call depth down to 1
2026-02-12 21:56:00 -05:00
Tom Moor 5d749efd84 fix: Issue in active context creation due to fallback (#11426) 2026-02-12 20:10:53 -05:00
Copilot 0363481a6a Add "Rename" option to sidebar context menus (#11425)
* Initial plan

* Add Rename option to context menus for sidebar items

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-13 00:25:03 +00:00
Copilot c8fbdc35fb Ignore table_of_contents blocks in Notion import (#11424)
* Initial plan

* feat: Add handler to ignore table_of_contents Notion block

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-12 18:51:43 -05:00
Copilot c382e1233b Convert markdown frontmatter to YAML codeblocks on import (#11420)
* Initial plan

* Add frontmatter to YAML codeblock conversion

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

* Add edge case tests and fix frontmatter regex, install types

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

* Address code review feedback - improve template literal readability

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-12 18:32:15 -05:00
Tom Moor 3a875d4466 Add more ignore rules (#11419) 2026-02-12 18:27:54 -05:00
Tom Moor 66f9113975 fix: Exporting document with table causes crash (#11422)
* fix: Exporting document with table causes crash

* fix: Same issue for checkbox lists
2026-02-12 18:27:42 -05:00
Tom Moor a52391842f chore: Add application_name to postgres logging (#11415) 2026-02-11 20:59:39 -05:00
Tom Moor 20e84c8e1d chore: Allowlist more methods for CSRF skip (#11414) 2026-02-11 20:38:40 -05:00
Tom Moor 1488341f66 fix: Remove unnecessary loading of authentication rows in userProvisioner (#11413)
* fix: Remove unneccessary loading of authentication rows in userProvisioner

* test
2026-02-11 18:45:47 -05:00
Tom Moor a06174b627 Revert "perf: Reduce database contention in ImportTask (#11361)" (#11411)
This reverts commit 8209f56e56.
2026-02-10 22:59:46 -05:00
Tom Moor 22556b2121 fix: More selection toolbar fixes around link selection (#11408) 2026-02-10 21:26:11 -05:00
Copilot 7252701e9b Preserve alignment and caption when replacing images (#11407)
* Initial plan

* Preserve alignment, caption, and height when replacing images

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-10 18:16:22 -05:00
Tom Moor 5fd6ef646a fix: Sentry error resulting from browser extensions using MobX (#11399) 2026-02-10 06:46:36 -05:00
Copilot 0e9f34bd6a Add hide/show completed items control for checkbox lists (#11379)
* Initial plan

* Add hide/show completed items feature for checkbox lists

- Add id attribute to checkbox_list nodes
- Create CheckboxListNodeView with toggle button
- Store hide state in localStorage per user and list
- Add CSS styles for wrapper and toggle button
- Hide completed items when state is active

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

* Address code review feedback - improve boolean handling and default values

- Change id default from undefined to null for consistent serialization
- Use !! for boolean coercion instead of === true for Storage.get
- More robust handling of truthy values

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

* Refactor

---------

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-02-09 16:35:58 -05:00
Copilot 23177578b2 Add context menu support for table rows in settings (#11378)
* Initial plan

* Add context menu support for table rows in settings

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

* Fix file formatting

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

* Add context menu support to all settings tables

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

* refactor

* Reuse hooks

* EmojiMenu

---------

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-02-09 14:43:17 -05:00
Copilot 40bbfc78cd Refactor: Extract Redis cache key generation to RedisPrefixHelper (#11376)
* Initial plan

* Refactor Redis cache keys: delegate CacheHelper to RedisPrefixHelper and update callers

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

* Add JSDoc documentation to getCollectionDocumentsKey method

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

* Remove unused indirection

* Remove mock

---------

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-02-09 14:03:02 -05:00
Tom Moor dc9aad99e9 fix: Test snapshot (#11395) 2026-02-08 18:28:35 -05:00
Copilot ea9e9675fb Fix document creation routing to use correct parameter name (#11369)
* Initial plan

* Fix: Use correct route parameter name in DocumentNew

The route parameter is 'collectionSlug', not 'id'. This caused documents
created through /collection/:collectionSlug/new to not have a collectionId,
making them go to drafts instead of the collection.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-08 18:21:06 -05:00
github-actions[bot] db42af7fe1 chore: Compressed inefficient images automatically (#11394)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2026-02-08 16:26:32 -05:00
Tom Moor eb59aed5b7 test: Fix snap (#11391) 2026-02-07 22:07:41 +00:00
Tom Moor 8209f56e56 perf: Reduce database contention in ImportTask (#11361)
* perf: Reduce database contention in ImportTask

* fix: Reuse transaction when available
2026-02-07 17:02:35 -05:00
Copilot a097676e9c Map Notion toggle blocks to container_toggle nodes (#11371)
* Initial plan

* Add toggle block support to Notion importer

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

* Support toggle headings

---------

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-02-07 16:40:06 -05:00
1337 changed files with 93735 additions and 33530 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": {
+8 -1
View File
@@ -1,4 +1,3 @@
__mocks__
.git
.vscode
.github
@@ -8,11 +7,19 @@ __mocks__
.eslint*
.oxlintrc*
.log
*.md
Makefile
Procfile
app.json
crowdin.yml
lint-staged.config.mjs
build
docker-compose.yml
node_modules
.yarn
**/*.test.ts
**/*.test.tsx
**/*.test.js
**/*.test.jsx
**/__tests__
**/__mocks__
+39 -3
View File
@@ -1,5 +1,21 @@
NODE_ENV=production
# –––––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE-BASED SECRETS ––––––––
# –––––––––––––––––––––––––––––––––––––––––
#
# Any environment variable can be loaded from a file by appending _FILE to the
# variable name and setting the value to the path of the file. This is useful
# for Docker secrets and other file-based secret management systems.
#
# For example, instead of:
# SECRET_KEY=your_secret_key
# You can use:
# SECRET_KEY_FILE=/run/secrets/outline_secret_key
#
# The file contents will be trimmed of leading/trailing whitespace. If both the
# variable and the _FILE variant are set, the direct variable takes precedence.
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
@@ -119,14 +135,23 @@ SSL_CERT=
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# When behind a reverse proxy, the header to use for the client IP.
# The default value is "X-Forwarded-For", common values are "X-Real-IP"
# 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 ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# Third party signin credentials, at least ONE OF these is required for a
# working installation or you'll have no sign-in options.
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
@@ -198,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 –––––––––––
@@ -212,6 +243,11 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The GitLab integration allows previewing issue and merge request links
# DOCS:
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
+14
View File
@@ -17,6 +17,11 @@ 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
OIDC_CLIENT_ID=client-id
OIDC_CLIENT_SECRET=client-secret
@@ -26,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
@@ -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
+39 -99
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 22.x
uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ 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: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ 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: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ 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: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ 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: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ 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]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
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: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ 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 }}
+24 -30
View File
@@ -1,9 +1,10 @@
name: Docker
name: Publish build
on:
push:
tags:
- "v*"
workflow_dispatch:
env:
IMAGE_NAME: outlinewiki/outline
@@ -11,17 +12,17 @@ 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@v3
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -30,14 +31,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile.base
@@ -45,13 +46,11 @@ 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
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.IMAGE_NAME }}
@@ -61,7 +60,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v6
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,17 +87,17 @@ 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@v3
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -109,14 +106,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: Dockerfile.base
@@ -124,13 +121,11 @@ 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
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.IMAGE_NAME }}
@@ -140,7 +135,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v6
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
@@ -182,17 +176,17 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_NAME }}
tags: |
+94
View File
@@ -0,0 +1,94 @@
name: Update Node.js LTS
on:
schedule:
# Run every Monday at 9:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-node:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Check for Node.js LTS update
id: check
run: |
# Get current Node version from Dockerfile
CURRENT_VERSION=$(grep -oP 'FROM node:\K[0-9]+\.[0-9]+\.[0-9]+' Dockerfile.base)
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
echo "Current Node.js version: $CURRENT_VERSION"
# Fetch the latest LTS release (any major version) from nodejs.org
LATEST_VERSION=$(curl -s https://nodejs.org/dist/index.json | \
jq -r '[.[] | select(.lts != false)][0].version' | \
sed 's/^v//')
if ! [[ "$LATEST_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Failed to fetch a valid LTS version (got '$LATEST_VERSION')"
exit 1
fi
echo "latest=$LATEST_VERSION" >> "$GITHUB_OUTPUT"
echo "Latest Node.js LTS version: $LATEST_VERSION"
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
echo "updated=false" >> "$GITHUB_OUTPUT"
echo "Already up to date."
else
echo "updated=true" >> "$GITHUB_OUTPUT"
echo "Update available: $CURRENT_VERSION -> $LATEST_VERSION"
fi
- name: Update Node.js version references
if: steps.check.outputs.updated == 'true'
env:
CURRENT: ${{ steps.check.outputs.current }}
LATEST: ${{ steps.check.outputs.latest }}
run: |
CURRENT_MAJOR=$(echo "$CURRENT" | cut -d. -f1)
LATEST_MAJOR=$(echo "$LATEST" | cut -d. -f1)
# Update Dockerfiles
sed -i "s/node:${CURRENT}-slim/node:${LATEST}-slim/g" Dockerfile
sed -i "s/node:${CURRENT} /node:${LATEST} /g" Dockerfile.base
# Update references that depend on major version
if [ "$CURRENT_MAJOR" != "$LATEST_MAJOR" ]; then
# .nvmrc
echo "$LATEST_MAJOR" > .nvmrc
# CI workflow: step name, node-version, and cache keys
sed -i "s/Use Node.js ${CURRENT_MAJOR}.x/Use Node.js ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
sed -i "s/node-version: ${CURRENT_MAJOR}.x/node-version: ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
# Update cache keys: replace node-modules-[optional old version] with new version
sed -i -E "s/node-modules-([0-9]+\.x-)?/node-modules-${LATEST_MAJOR}.x-/g" .github/workflows/ci.yml
# package.json engines field: append new major version
sed -i "s/\"node\": \"\(.*\)\"/\"node\": \"\1 || ${LATEST_MAJOR}\"/" package.json
fi
echo "Updated Node.js from $CURRENT to $LATEST"
- name: Create pull request
if: steps.check.outputs.updated == 'true'
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 }}"
body: |
Automated update of Node.js in Docker images.
- **Previous version:** ${{ steps.check.outputs.current }}
- **New version:** ${{ steps.check.outputs.latest }}
[Release notes](https://nodejs.org/en/blog/release/v${{ steps.check.outputs.latest }})
branch: automated/update-node-lts
delete-branch: true
labels: dependencies
+2
View File
@@ -14,10 +14,12 @@ data/*
*.pem
*.key
*.cert
.history
# Yarn Berry
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
.yarn/releases
!.yarn/sdks
-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"
}
}
]
}
+1 -1
View File
@@ -1 +1 @@
22
24
+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",
{
+12 -1
View File
@@ -1,3 +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)
+16 -3
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.
@@ -70,7 +82,7 @@ yarn install
### Exports
- Exported members must appear at the top of the file.
- Prefer named exports for components & classes.
- Always use named exports for new components & classes.
- Document ALL public/exported functions with JSDoc.
## React Usage
@@ -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:22.21.0-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:22.21.0 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.4.0
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-01-27
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 we'd love your help to fix bugs and add features!
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
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 }) =>
+8 -11
View File
@@ -29,8 +29,8 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
createActionWithChildren,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
@@ -152,7 +152,7 @@ export const importDocument = createAction({
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
perform: ({ getActiveModel, stores }) => {
perform: ({ t, getActiveModel, stores }) => {
const { documents } = stores;
const collection = getActiveModel(Collection);
if (!collection) {
@@ -165,6 +165,7 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(file, null, collection.id, {
@@ -173,6 +174,8 @@ export const importDocument = createAction({
history.push(document.path);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -525,17 +528,11 @@ export const createTemplate = createInternalLinkAction({
keywords: "new create template",
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
(policy) => policy.abilities.createTemplate
),
to: ({ getActiveModel, sidebarContext }) => {
to: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newTemplatePath(collection?.id).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
return newTemplatePath(collection?.id);
},
});
+296 -148
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,
@@ -32,22 +32,25 @@ import {
CaseSensitiveIcon,
RestoreIcon,
EditIcon,
EmbedIcon,
OpenIcon,
} from "outline-icons";
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 DocumentMove from "~/scenes/DocumentMove";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
import { DocumentDownload } from "~/components/DocumentDownload";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -70,17 +73,27 @@ import {
homePath,
newDocumentPath,
newNestedDocumentPath,
newSiblingDocumentPath,
searchPath,
documentPath,
urlify,
desktopify,
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type { Action, ActionGroup, ActionSeparator } from "~/types";
import type {
Action,
ActionContext,
ActionGroup,
ActionSeparator,
} from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import env from "~/env";
import { isMac, isWindows } from "@shared/utils/browser";
import isCloudHosted from "~/utils/isCloudHosted";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -96,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}
@@ -116,12 +131,12 @@ export const openDocument = createActionWithChildren({
color={item.color ?? undefined}
/>
) : (
<DocumentIcon />
<DocumentIcon outline={item.isDraft} />
),
section: DocumentSection,
to: item.url,
})
);
});
});
},
});
@@ -132,18 +147,13 @@ export const editDocument = createInternalLinkAction({
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, documents, policies } = stores;
const { auth, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return (
!!can?.update && !!auth.user?.separateEditMode && !document?.template
);
return !!can?.update && !!auth.user?.separateEditMode;
},
to: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
@@ -200,59 +210,61 @@ export const createDraftDocument = createInternalLinkAction({
}),
});
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({
currentTeamId,
activeCollectionId,
activeDocumentId,
stores,
}) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
/**
* Finds the index of a document among its siblings in the collection tree.
*
* @param stores - the root stores.
* @param document - the document to find the index of.
* @returns the index of the document among its siblings, or -1 if not found.
*/
function findDocumentSiblingIndex(
stores: ActionContext["stores"],
document: {
id: string;
collectionId?: string | null;
parentDocumentId?: string;
}
): number {
if (!document.collectionId) {
return -1;
}
const collection = stores.collections.get(document.collectionId);
if (!collection) {
return -1;
}
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
return false;
}
const siblings = document.parentDocumentId
? collection.getChildrenForDocument(document.parentDocumentId)
: collection.sortedDocuments;
if (activeCollectionId) {
return stores.policies.abilities(activeCollectionId).createDocument;
}
return stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
if (!activeDocumentId || !activeCollectionId) {
return "";
}
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
}
const [pathname, search] = newDocumentPath(activeCollectionId, {
templateId: activeDocumentId,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
/**
* 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("New nested document"),
name: ({ t }) => t("Nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
keywords: "create nested",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
@@ -270,6 +282,151 @@ export const createNestedDocument = createInternalLinkAction({
},
});
const createDocumentBefore = createInternalLinkAction({
name: ({ t }) => t("Before"),
analyticsName: "New document before",
section: ActiveDocumentSection,
keywords: "create before",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
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
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: Math.max(0, index),
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
const createDocumentAfter = createInternalLinkAction({
name: ({ t }) => t("After"),
analyticsName: "New document after",
section: ActiveDocumentSection,
keywords: "create after",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
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
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: index + 1,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
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",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId || !currentTeamId) {
return false;
}
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",
@@ -346,7 +503,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId || document?.template) {
if (document?.collectionId) {
await document.save(undefined, {
publish: true,
});
@@ -495,7 +652,10 @@ export const shareDocument = createAction({
section: ActiveDocumentSection,
icon: <PadlockIcon />,
visible: ({ stores, activeDocumentId }) => {
const can = stores.policies.abilities(activeDocumentId!);
if (!activeDocumentId) {
return false;
}
const can = stores.policies.abilities(activeDocumentId);
return can.manageUsers || can.share;
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -652,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"));
}
@@ -870,7 +1028,59 @@ export const printDocument = createAction({
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
perform: () => {
queueMicrotask(window.print);
setTimeout(window.print, 0);
},
});
export const openDocumentInDesktop = createAction({
name: ({ t }) => t("Open in desktop app"),
analyticsName: "Open in desktop",
section: ActiveDocumentSection,
icon: <OpenIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
isCloudHosted &&
(isMac || isWindows) &&
!!document &&
!document.isDeleted &&
!isMobile()
);
},
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
window.location.href = desktopify(documentPath(document));
}
},
});
export const presentDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")),
analyticsName: "Present document",
section: ActiveDocumentSection,
icon: <EmbedIcon />,
shortcut: ["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;
if (!document) {
return;
}
stores.ui.setPresentingDocument(document);
},
});
@@ -891,7 +1101,7 @@ export const importDocument = createAction({
return false;
},
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
perform: ({ t, activeDocumentId, activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
@@ -900,6 +1110,7 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(
@@ -913,6 +1124,8 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -930,12 +1143,12 @@ export const createTemplateFromDocument = createAction({
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document?.isTemplate || !document?.isActive) {
if (!document?.isActive) {
return false;
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).updateDocument
stores.policies.abilities(activeCollectionId).createTemplate
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -982,46 +1195,8 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
@@ -1059,8 +1234,7 @@ export const moveDocument = createAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
if (!document) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
@@ -1068,25 +1242,6 @@ export const moveDocument = createAction({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
@@ -1145,10 +1300,7 @@ export const restoreDocument = createAction({
: undefined;
const can = stores.policies.abilities(document.id);
return (
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
return !!collection?.isActive && !!(can.restore || can.unarchive);
},
perform: async ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
@@ -1185,10 +1337,7 @@ export const restoreDocumentToCollection = createActionWithChildren({
? stores.collections.get(document.collectionId)
: undefined;
return (
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
return !collection?.isActive && !!(can.restore || can.unarchive);
},
children: ({ t, activeDocumentId, stores }) => {
const { collections, documents, policies } = stores;
@@ -1330,7 +1479,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.toggleComments();
stores.ui.set({ rightSidebar: "comments" });
},
});
@@ -1365,6 +1514,7 @@ export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
shortcut: [`Meta+Shift+I`],
icon: <GraphIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1372,12 +1522,7 @@ export const openDocumentInsights = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
return (
!!activeDocumentId &&
can.listViews &&
!document?.isTemplate &&
!document?.isDeleted
);
return !!activeDocumentId && can.listViews && !document?.isDeleted;
},
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
@@ -1456,6 +1601,8 @@ export const rootDocumentActions = [
archiveDocument,
createDocument,
createDraftDocument,
createNewDocument,
createNewDocumentInAlphabeticalCollection,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
@@ -1477,16 +1624,17 @@ export const rootDocumentActions = [
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
presentDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
shareDocument,
];
+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) {
+230
View File
@@ -0,0 +1,230 @@
import copy from "copy-to-clipboard";
import {
CaseSensitiveIcon,
CollectionIcon,
CopyIcon,
MoveIcon,
NewDocumentIcon,
PlusIcon,
PrintIcon,
TrashIcon,
} from "outline-icons";
import { Trans } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import history from "~/utils/history";
import {
newDocumentPath,
newTemplatePath,
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";
import TeamLogo from "~/components/TeamLogo";
export const createTemplate = createInternalLinkAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: TemplateSection,
icon: <PlusIcon />,
keywords: "new create template",
visible: ({ currentTeamId, stores }) =>
!!stores.policies.abilities(currentTeamId!).createTemplate,
to: newTemplatePath(),
});
export const deleteTemplate = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete template",
section: ActiveTemplateSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: t("template"),
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await template.delete();
history.push(settingsPath("templates"));
toast.success(t("Template deleted"));
}}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
values={{
templateName: template.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
),
});
},
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: ActiveTemplateSection,
icon: ({ stores }) => {
const { team } = stores.auth;
return <TeamLogo model={team} size={AvatarSize.Small} />;
},
visible: ({ getActiveModel }) => {
const template = getActiveModel(Template);
return !!template?.collectionId;
},
perform: async ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
try {
await template.save({ collectionId: null });
toast.success(t("Template moved"));
stores.dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldn't move the template, try again?"));
}
},
});
export const moveTemplateToCollection = createAction({
name: ({ t }) => t("Move to collection"),
analyticsName: "Move template to collection",
section: ActiveTemplateSection,
icon: <CollectionIcon />,
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Move template"),
content: <TemplateMove template={template} />,
});
},
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move template",
section: ActiveTemplateSection,
icon: <MoveIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.move),
children: [moveTemplateToWorkspace, moveTemplateToCollection],
});
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document from template",
section: ActiveTemplateSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, getActiveModel, stores }) => {
const template = getActiveModel(Template);
if (!template || !currentTeamId) {
return false;
}
if (template.collectionId) {
return !!stores.policies.abilities(template.collectionId).createDocument;
}
return !!stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
const template = getActiveModel(Template);
if (!template) {
return "";
}
const collectionId = template?.collectionId ?? activeCollectionId;
const [pathname, search] = newDocumentPath(collectionId, {
templateId: template.id,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const copyTemplateLink = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy template link",
section: ActiveTemplateSection,
icon: <CopyIcon />,
iconInContextMenu: false,
perform: ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
copy(urlify(template.path));
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyTemplateAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
analyticsName: "Copy template as text",
section: ActiveTemplateSection,
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
perform: async ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
copy(ProsemirrorHelper.toPlainText(template));
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyTemplate = createActionWithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy template",
section: ActiveTemplateSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyTemplateLink, copyTemplateAsPlainText],
});
export const printTemplate = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
analyticsName: "Print template",
section: ActiveTemplateSection,
icon: <PrintIcon />,
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
perform: () => {
setTimeout(window.print, 0);
},
});
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
+12 -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,
@@ -210,6 +217,7 @@ export function actionToKBar(
const name = resolve<string>(action.name, context);
const icon = resolve<React.ReactElement>(action.icon, context);
const section = resolve<string>(action.section, context);
const subtitle = resolve<string>(action.description, context);
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
@@ -229,6 +237,7 @@ export function actionToKBar(
section,
keywords: action.keywords,
shortcut: action.shortcut,
subtitle,
icon,
priority,
perform: () => performAction(action, context),
@@ -254,6 +263,7 @@ export function actionToKBar(
keywords: action.keywords,
shortcut: action.shortcut,
icon,
subtitle,
priority,
},
...children.map((child) => ({
+17 -1
View File
@@ -13,8 +13,15 @@ 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) =>
t("Search results");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
@@ -24,6 +31,15 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
ActiveDocumentSection.priority = 0.9;
export const TemplateSection = ({ t }: ActionContext) => t("Template");
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
const activeTemplate = stores.templates.active;
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
};
ActiveTemplateSection.priority = 0.9;
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
RecentSection.priority = 1;
@@ -49,7 +65,7 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
t("Recently viewed");
RecentSearchesSection.priority = -0.1;
+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";
-14
View File
@@ -1,14 +0,0 @@
export default function Arrow() {
return (
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z" />
<path d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z" />
</svg>
);
}
+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="/" />;
};
+51 -67
View File
@@ -1,42 +1,34 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { TeamPreference } from "@shared/types";
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,
matchDocumentHistory,
matchDocumentSlug as slug,
homePath,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import NotificationBadge from "./NotificationBadge";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments/Comments")
);
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const SettingsSidebar = lazyWithRetry(
() => import("~/components/Sidebar/Settings")
);
@@ -49,11 +41,16 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const can = usePolicy(ui.activeDocumentId);
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();
@@ -73,67 +70,54 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
history.push(newDocumentPath(activeCollectionId));
};
React.useEffect(() => {
const postLoginPath = spendPostLoginPath();
if (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]);
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
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>
);
const showHistory =
!!matchPath(location.pathname, {
path: matchDocumentHistory,
}) && can.listRevisions;
const showComments =
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded &&
!!team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</Route>
)}
</AnimatePresence>
);
return (
<DocumentContextProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout
title={team.name}
sidebar={sidebar}
sidebarRight={sidebarRight}
ref={layoutRef}
>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
</Layout>
</PortalContext.Provider>
<RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<DndProvider backend={EditorAwareHTML5Backend}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</DndProvider>
</PortalContext.Provider>
</RightSidebarProvider>
</DocumentContextProvider>
);
};
+2 -1
View File
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import useBoolean from "~/hooks/useBoolean";
@@ -109,4 +110,4 @@ const Image = styled.img<{ size: number }>`
height: ${(props) => props.size}px;
`;
export default Avatar;
export default observer(Avatar);
+16 -8
View File
@@ -1,4 +1,5 @@
import { GoToIcon } from "outline-icons";
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -54,6 +55,15 @@ function Breadcrumb(
});
}
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.currentTarget.querySelector('[data-state="open"]')) {
event.preventDefault();
}
},
[]
);
const toBreadcrumb = React.useCallback(
(action: TopLevelAction, index: number) => {
if (action.type === "menu") {
@@ -67,6 +77,7 @@ function Breadcrumb(
{item.icon}
<Item
to={item.to}
onClick={handleClick}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
@@ -75,7 +86,7 @@ function Breadcrumb(
</>
);
},
[actionContext, highlightFirstItem]
[actionContext, handleClick, highlightFirstItem]
);
return (
@@ -102,23 +113,20 @@ 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;
}
`;
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
+10 -3
View File
@@ -3,6 +3,8 @@ import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import type { HapticInput } from "web-haptics";
import { useWebHaptics } from "web-haptics/react";
import { s } from "@shared/styles";
import type { Props as ActionButtonProps } from "~/components/ActionButton";
import ActionButton from "~/components/ActionButton";
@@ -123,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<{
@@ -133,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;"};
`;
@@ -152,6 +154,8 @@ export type Props<T> = ActionButtonProps & {
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
haptic?: HapticInput;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
@@ -176,11 +180,13 @@ const Button = <T extends React.ElementType = "button">(
hideIcon,
fullwidth,
danger,
haptic,
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : (action?.icon ?? icon);
const hasIcon = ic !== undefined;
const { trigger } = useWebHaptics();
return (
<RealButton
@@ -191,6 +197,7 @@ const Button = <T extends React.ElementType = "button">(
$danger={danger}
$fullwidth={fullwidth}
$borderOnHover={borderOnHover}
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
{...rest}
>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
+2 -5
View File
@@ -23,12 +23,9 @@ const Container = styled.div<Props>`
type ContentProps = { $maxWidth?: string };
const Content = styled.div<ContentProps>`
max-width: ${(props) => props.$maxWidth ?? "46em"};
max-width: ${(props: ContentProps) =>
props.$maxWidth ?? EditorStyleHelper.documentWidth};
margin: 0 auto;
${breakpoint("desktopLarge")`
max-width: ${(props: ContentProps) => props.$maxWidth ?? EditorStyleHelper.documentWidth};
`};
`;
const CenteredContent: React.FC<Props> = ({
+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 (
+12 -8
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(() => {
@@ -125,8 +122,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
{...rest}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
@@ -146,7 +143,14 @@ function Collaborators(props: Props) {
/>
);
},
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
[
presentIds,
editingIds,
observingUserId,
currentUserId,
handleAvatarClick,
t,
]
);
if (!document.insightsEnabled) {
+112
View File
@@ -0,0 +1,112 @@
import * as RadixCollapsible from "@radix-ui/react-collapsible";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
interface CollapsibleProps {
/** The label displayed on the trigger button. */
label: React.ReactNode;
/** The content to show/hide inside the collapsible panel. */
children: React.ReactNode;
/** Whether the collapsible is open by default. */
defaultOpen?: boolean;
/** Controlled open state. */
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: (open: boolean) => void;
/** Additional class name for the root element. */
className?: string;
}
/**
* An accessible collapsible section built on Radix UI Collapsible.
* Renders a trigger button with a disclosure chevron and animated content panel.
*
* @param props - component props.
* @returns the collapsible component.
*/
export function Collapsible({
label,
children,
defaultOpen = false,
open,
onOpenChange,
className,
}: CollapsibleProps) {
return (
<RadixCollapsible.Root
defaultOpen={defaultOpen}
open={open}
onOpenChange={onOpenChange}
className={className}
>
<StyledTrigger>
<StyledExpandedIcon aria-hidden="true" />
{label}
</StyledTrigger>
<StyledContent>{children}</StyledContent>
</RadixCollapsible.Root>
);
}
const StyledExpandedIcon = styled(ExpandedIcon)`
flex-shrink: 0;
transition: transform 150ms ease-out;
margin-left: -4px;
`;
const StyledTrigger = styled(RadixCollapsible.Trigger)`
display: flex;
align-items: center;
background: none;
border: none;
padding: 0 0 8px 0;
cursor: var(--pointer);
color: ${s("textTertiary")};
font-size: 14px;
&:hover {
color: ${s("textSecondary")};
}
&[data-state="closed"] {
${StyledExpandedIcon} {
transform: rotate(-90deg);
}
}
`;
const StyledContent = styled(RadixCollapsible.Content)`
overflow: hidden;
&[data-state="open"] {
animation: slideDown 200ms ease-out;
}
&[data-state="closed"] {
animation: slideUp 200ms ease-out;
}
@keyframes slideDown {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes slideUp {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
`;
+103 -38
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";
@@ -6,14 +6,16 @@ import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import type { CollectionPermission } from "@shared/types";
import { TeamPreference } from "@shared/types";
import { CollectionPermission, TeamPreference } from "@shared/types";
import type { Option } from "~/components/InputSelect";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import Button from "~/components/Button";
import { Collapsible } from "~/components/Collapsible";
import Input from "~/components/Input";
import { InputSelect } from "~/components/InputSelect";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
@@ -23,17 +25,19 @@ 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;
sharing: boolean;
permission: CollectionPermission | undefined;
commenting?: boolean | null;
}
templateManagement: CollectionPermission;
};
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
@@ -64,9 +68,26 @@ export const CollectionForm = observer(function CollectionForm_({
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const dialog = useDialogContext();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const templateManagementOptions = useMemo<Option[]>(
() => [
{
type: "item",
label: t("Managers"),
value: CollectionPermission.Admin,
},
{
type: "item",
label: t("Members"),
value: CollectionPermission.ReadWrite,
},
],
[t]
);
const iconColor = useIconColor(collection);
const fallbackIcon = (
<Icon
@@ -92,6 +113,8 @@ export const CollectionForm = observer(function CollectionForm_({
sharing: collection?.sharing ?? true,
permission: collection?.permission,
commenting: collection?.commenting ?? true,
templateManagement:
collection?.templateManagement ?? CollectionPermission.Admin,
color: iconColor,
},
});
@@ -134,6 +157,71 @@ export const CollectionForm = observer(function CollectionForm_({
const initial = values.name.charAt(0).toUpperCase();
const options = (
<>
<Controller
control={control}
name="templateManagement"
render={({ field }) => (
<>
<InputSelect
value={field.value}
onChange={(value: string) => {
field.onChange(value as CollectionPermission);
}}
options={templateManagementOptions}
label={t("Manage templates")}
/>
<Text
type="secondary"
size="small"
as="p"
style={{ paddingTop: 4 }}
>
{t(
"Choose who can create and edit templates in this collection."
)}
</Text>
</>
)}
/>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
</>
);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
@@ -144,7 +232,7 @@ export const CollectionForm = observer(function CollectionForm_({
<HStack>
<Input
type="text"
placeholder={t("Name")}
label={t("Name")}
{...register("name", {
required: true,
maxLength: CollectionValidation.maxNameLength,
@@ -189,38 +277,15 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
{collection ? (
options
) : (
<Collapsible
label={t("Advanced options")}
onOpenChange={() => dialog.setAnimating(true)}
>
{options}
</Collapsible>
)}
<HStack justify="flex-end">
+28 -6
View File
@@ -3,7 +3,8 @@ 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";
import Text from "~/components/Text";
@@ -15,14 +16,22 @@ type Props = {
currentRootActionId: string | null | undefined;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function CommandBarItem(
{ action, active, currentRootActionId }: Props,
ref: React.RefObject<HTMLDivElement>
) {
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
@@ -56,6 +65,16 @@ function CommandBarItem(
))}
{action.name}
{action.children?.length ? "…" : ""}
{action.subtitle && (
<Text type="secondary" ellipsis>
&nbsp;&nbsp;
<Highlight
text={action.subtitle}
highlight={SEARCH_RESULT_REGEX}
processResult={replaceResultMarks}
/>
</Text>
)}
</Content>
{action.shortcut?.length ? (
<Shortcut>
@@ -71,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;
`;
@@ -0,0 +1,94 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import CommandBarResults from "./CommandBarResults";
import SharedSearchActions from "./SharedSearchActions";
/**
* A simplified command bar for public shares that only provides search.
*/
function SharedCommandBar() {
const { t } = useTranslation();
return (
<>
<SharedSearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchInput defaultPlaceholder={`${t("Search")}`} />
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
</>
);
}
type Props = {
children?: React.ReactNode;
};
const KBarPortal: React.FC = ({ children }: Props) => {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
};
const Positioner = styled(KBarPositioner)`
z-index: ${depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
position: relative;
padding: 16px 12px;
margin: 0 8px;
width: calc(100% - 16px);
outline: none;
border: none;
background: ${s("menuBackground")};
color: ${s("text")};
&:not(:last-child) {
border-bottom: 1px solid ${s("inputBorder")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
opacity: 1;
}
`;
const Animator = styled(KBarAnimator)`
max-width: 600px;
max-height: 75vh;
width: 90vw;
background: ${s("menuBackground")};
color: ${s("text")};
border-radius: 8px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
transition: max-width 0.2s ease-in-out;
${breakpoint("desktopLarge")`
max-width: 740px;
`};
@media print {
display: none;
}
`;
export default observer(SharedCommandBar);
@@ -0,0 +1,187 @@
import { useKBar } from "kbar";
import { escapeRegExp } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import useShare from "@shared/hooks/useShare";
import { Minute } from "@shared/utils/time";
import { createAction } from "~/actions";
import {
RecentSearchesSection,
SearchResultsSection,
} from "~/actions/sections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
import type Document from "~/models/Document";
import history from "~/utils/history";
import { sharedModelPath } from "~/utils/routeHelpers";
import type { SearchResult } from "~/types";
interface CacheEntry {
timestamp: number;
results: SearchResult[];
}
const cacheTTL = Minute.ms * 5;
const maxRecentDocs = 5;
/**
* Strip server-generated `<b>` highlight tags from context and re-apply them
* using the current search query. This prevents stale highlights when the
* displayed results are from a previous (in-flight) query.
*
* @param context the server-generated context string with `<b>` tags.
* @param query the current search query to highlight.
* @returns the context string with highlights matching the current query.
*/
function rehighlightContext(
context: string | undefined,
query: string
): string | undefined {
if (!context) {
return context;
}
const plain = context.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
const trimmed = query.trim();
if (!trimmed) {
return plain;
}
const terms = trimmed.split(/\s+/).filter(Boolean);
const patterns = [escapeRegExp(trimmed)];
if (terms.length > 1) {
patterns.push(...terms.map((t) => `\\b${escapeRegExp(t)}\\b`));
}
const regex = new RegExp(patterns.join("|"), "gi");
return plain.replace(regex, "<b>$&</b>");
}
/**
* Registers search result actions in the command bar scoped to a public share.
*/
function SharedSearchActions() {
const { documents } = useStores();
const { shareId } = useShare();
const searchCache = React.useRef<Map<string, CacheEntry>>(new Map());
const [results, setResults] = React.useState<SearchResult[]>([]);
const recentDocsRef = React.useRef<Document[]>([]);
const [recentDocs, setRecentDocs] = React.useState<Document[]>([]);
const { searchQuery } = useKBar((state) => ({
searchQuery: state.searchQuery,
}));
const searchQueryRef = React.useRef(searchQuery);
searchQueryRef.current = searchQuery;
React.useEffect(() => {
if (!searchQuery || !shareId) {
setResults([]);
return;
}
const now = Date.now();
const cachedEntry = searchCache.current.get(searchQuery);
const isExpired = cachedEntry
? now - cachedEntry.timestamp > cacheTTL
: true;
if (cachedEntry && !isExpired) {
setResults(cachedEntry.results);
return;
}
const currentQuery = searchQuery;
void documents.search({ query: searchQuery, shareId }).then((res) => {
searchCache.current.set(currentQuery, { timestamp: now, results: res });
if (searchQueryRef.current === currentQuery) {
setResults(res);
}
});
}, [documents, searchQuery, shareId]);
const addRecentDoc = React.useCallback((doc: Document) => {
const prev = recentDocsRef.current;
const filtered = prev.filter((d) => d.id !== doc.id);
const next = [doc, ...filtered].slice(0, maxRecentDocs);
recentDocsRef.current = next;
setRecentDocs(next);
}, []);
const documentIcon = React.useCallback(
(doc: Document) =>
doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
[]
);
const actions = React.useMemo(
() =>
results.map((result) =>
createAction({
id: `shared-search-${result.document.id}`,
name: result.document.titleWithDefault,
description: rehighlightContext(result.context, searchQuery),
keywords: searchQuery,
analyticsName: "Open shared search result",
section: SearchResultsSection,
icon: documentIcon(result.document),
perform: () => {
if (shareId) {
const currentQuery = searchQueryRef.current;
addRecentDoc(result.document);
history.push({
pathname: sharedModelPath(shareId, result.document.url),
search: currentQuery
? `?q=${encodeURIComponent(currentQuery)}`
: undefined,
});
}
},
})
),
[results, shareId, searchQuery, addRecentDoc, documentIcon]
);
const recentDocActions = React.useMemo(
() =>
recentDocs.map((doc) =>
createAction({
id: `shared-recent-doc-${doc.id}`,
name: doc.titleWithDefault,
analyticsName: "Open recent shared document",
section: RecentSearchesSection,
icon: documentIcon(doc),
perform: () => {
if (shareId) {
history.push(sharedModelPath(shareId, doc.url));
}
},
})
),
[recentDocs, shareId, documentIcon]
);
useCommandBarActions(searchQuery ? actions : recentDocActions, [
searchQuery
? actions.map((a) => a.id).join("")
: recentDocActions.map((a) => a.id).join(""),
searchQuery,
]);
return null;
}
export default observer(SharedSearchActions);
@@ -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]
);
};
@@ -11,15 +11,15 @@ import useStores from "~/hooks/useStores";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { documents } = useStores();
const { templates } = useStores();
useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
void templates.fetchAll();
}, [templates]);
const actions = useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
templates.alphabetical.map((template) =>
createInternalLinkAction({
name: template.titleWithDefault,
analyticsName: "New document",
@@ -66,7 +66,7 @@ const useTemplatesAction = () => {
},
})
),
[documents.templatesAlphabetical]
[templates.alphabetical]
);
const newFromTemplate = useMemo(
+18 -10
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;
}
@@ -128,7 +129,14 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
React.useEffect(() => {
if (contentRef.current && value !== contentRef.current.textContent) {
setInnerValue(value);
if (document.activeElement === contentRef.current) {
// Don't reset content while the user is actively editing. Update
// lastValue so that the next input or blur event will push the
// current DOM text back to the model via onChange.
lastValue.current = value;
} else {
setInnerValue(value);
}
}
}, [value, contentRef]);
+105 -60
View File
@@ -1,8 +1,15 @@
import { HomeIcon } from "outline-icons";
import {
CollectionIcon as CollectionIconComponent,
HomeIcon,
PrivateCollectionIcon,
} from "outline-icons";
import { observer } from "mobx-react";
import { getLuminance } from "polished";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import type { Option } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import useStores from "~/hooks/useStores";
@@ -12,74 +19,112 @@ type DefaultCollectionInputSelectProps = {
defaultCollectionId: string | null;
};
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
const DefaultCollectionInputSelect = observer(
({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections, ui } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
}
}
}
}
void fetchData();
}, [fetchError, t, fetching, collections]);
void fetchData();
}, [fetchError, t, fetching, collections]);
const options: Option[] = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
if (fetching) {
return null;
}
const isDark = ui.resolvedTheme === "dark";
// Eagerly resolve collection icon properties within this observer context
// to avoid MobX warnings when Radix Select clones elements for the trigger.
const options: Option[] = collections.nonPrivate.reduce(
(acc, collection) => {
const collectionIcon = collection.icon;
const rawColor = collection.color ?? colorPalette[0];
let icon: React.ReactElement;
if (!collectionIcon || collectionIcon === "collection") {
const color =
isDark && rawColor !== "currentColor"
? getLuminance(rawColor) > 0.09
? rawColor
: "currentColor"
: rawColor;
const Component = collection.isPrivate
? PrivateCollectionIcon
: CollectionIconComponent;
icon = <Component color={color} />;
} else {
let color = rawColor;
if (color !== "currentColor") {
if (isDark) {
color = getLuminance(color) > 0.09 ? color : "currentColor";
} else {
color = getLuminance(color) < 0.9 ? color : "currentColor";
}
}
icon = (
<Icon
value={collectionIcon}
color={color}
initial={collection.initial}
forceColor
/>
);
}
return [
...acc,
{
type: "item",
type: "item" as const,
label: collection.name,
value: collection.id,
icon: <CollectionIcon collection={collection} />,
icon,
},
],
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
),
[collections.nonPrivate, t]
);
];
},
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
);
if (fetching) {
return null;
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
labelHidden
short
/>
);
}
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
short
/>
);
};
);
export default DefaultCollectionInputSelect;
+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>
);
}
-11
View File
@@ -1,11 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Divider = styled.hr`
border: 0;
border-bottom: 1px solid ${s("divider")};
margin: 0;
padding: 0;
`;
export default Divider;
+138 -35
View File
@@ -1,20 +1,75 @@
import type { TFunction } from "i18next";
import { observer } from "mobx-react";
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
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";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
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;
@@ -68,14 +123,9 @@ function DocumentBreadcrumb(
to: archivePath(),
}),
createInternalLinkAction({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkAction({
name: collection?.name,
name: collection ? (
<CollectionName collection={collection} />
) : undefined,
section: ActiveDocumentSection,
icon: collection ? (
<CollectionIcon collection={collection} expanded />
@@ -97,18 +147,20 @@ function DocumentBreadcrumb(
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkAction({
name: node.icon ? (
<>
<StyledIcon
value={node.icon}
color={node.color}
initial={node.title.charAt(0).toUpperCase()}
/>{" "}
{title}
</>
) : (
title
name: (
<DocumentName
documentId={node.id}
collection={collection}
title={title}
/>
),
icon: node.icon ? (
<Icon
value={node.icon}
color={node.color}
initial={title.charAt(0).toUpperCase()}
/>
) : undefined,
section: ActiveDocumentSection,
to: {
pathname: node.url,
@@ -145,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>
@@ -176,9 +231,57 @@ function DocumentBreadcrumb(
);
}
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
/** Renders a collection name wrapped in a context menu. */
const CollectionName = observer(function CollectionName_({
collection,
}: {
collection: Collection;
}) {
const { t } = useTranslation();
const menuAction = useCollectionMenuAction({
collectionId: collection.id,
});
return (
<ActionContextProvider value={{ activeModels: [collection] }}>
<ContextMenu action={menuAction} ariaLabel={t("Collection options")}>
<span>{collection.name}</span>
</ContextMenu>
</ActionContextProvider>
);
});
/** Renders a document name wrapped in a context menu. */
const DocumentName = observer(function DocumentName_({
documentId,
collection,
title,
}: {
documentId: string;
collection: Collection | undefined;
title: string;
}) {
const { t } = useTranslation();
const { documents } = useStores();
const doc = documents.get(documentId);
const menuAction = useDocumentMenuAction({ documentId });
if (!doc) {
return <>{title}</>;
}
return (
<ActionContextProvider
value={{
activeModels: [doc, ...(collection ? [collection] : [])],
}}
>
<ContextMenu action={menuAction} ariaLabel={t("Document options")}>
<span>{title}</span>
</ContextMenu>
</ActionContextProvider>
);
});
const SmallSlash = styled(GoToIcon)`
width: 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,
},
@@ -0,0 +1,17 @@
import styled from "styled-components";
import Flex from "../Flex";
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
flex-shrink: 0;
`;
@@ -5,13 +5,13 @@ import { toast } from "sonner";
import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import Switch from "./Switch";
import Text from "./Text";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
/** The original document to duplicate */
@@ -37,16 +37,11 @@ function DocumentCopy({ document, onSubmit }: Props) {
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
}, [policies, collectionTrees]);
const copy = async () => {
if (!selectedPath) {
const copy = async (path = selectedPath) => {
if (!path) {
toast.message(t("Select a location to copy"));
return;
}
@@ -57,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"));
@@ -80,34 +73,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
@@ -117,8 +108,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
) : (
t("Select a location to copy")
)}
</StyledText>
<Button disabled={!selectedPath || copying} onClick={copy}>
</Text>
<Button disabled={!selectedPath || copying} onClick={() => copy()}>
{copying ? `${t("Copying")}` : t("Copy")}
</Button>
</Footer>
@@ -1,29 +1,29 @@
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";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
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";
@@ -31,16 +31,46 @@ 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 */
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
/** Whether to show child documents */
showDocuments?: boolean;
};
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
function DocumentExplorer({
onSubmit,
onSelect,
items,
defaultValue,
showDocuments,
}: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -59,8 +89,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return node || null;
}
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
@@ -83,9 +111,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
);
const listRef = React.useRef<List<NavigationNode[]>>(null);
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const searchIndex = React.useMemo(
() =>
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
@@ -136,24 +161,18 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, [defaultValue, selectedNode, nodes]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
if (itemRefs[node] && itemRefs[node].current) {
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
behavior: "auto",
block: "center",
});
}
},
[itemRefs]
);
const scrollNodeIntoView = React.useCallback((node: number) => {
listRef.current?.scrollToItem(node, "smart");
}, []);
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(ev.target.value);
@@ -161,16 +180,16 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
const calculateInitialScrollOffset = (itemCount: number) => {
const preserveScrollOffset = (itemCount: number) => {
if (listRef.current) {
const { height, itemSize } = listRef.current.props;
const { scrollOffset } = listRef.current.state as {
scrollOffset: number;
};
const itemsHeight = itemCount * itemSize;
return itemsHeight < Number(height) ? 0 : scrollOffset;
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
setTimeout(() => listRef.current?.scrollTo(offset), 0);
}
return 0;
};
const collapse = (node: number) => {
@@ -181,8 +200,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
// remove children
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
preserveScrollOffset(newNodes.length);
};
const expand = (node: number) => {
@@ -191,8 +209,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
// add children
const newNodes = nodes.slice();
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
preserveScrollOffset(newNodes.length);
};
React.useEffect(() => {
@@ -216,7 +233,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 || nodes[node].type === "collection";
nodes[node].children.length > 0 ||
(showDocuments !== false && nodes[node].type === "collection");
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -237,6 +255,13 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
}
};
const submitNode = (node: number) => {
const selectedNode = nodes[node];
selectNode(selectedNode);
onSubmit(selectedNode);
};
const ListItem = observer(
({
index,
@@ -293,7 +318,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
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}
@@ -307,7 +333,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
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);
@@ -369,7 +396,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
}
case "Enter": {
if (isModKey(ev)) {
onSubmit();
onSubmit(selectedNode);
} else {
toggleSelect(activeNode);
}
@@ -378,31 +405,16 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
}
};
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
return (
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
<ListSearch
ref={inputSearchRef}
onChange={handleSearch}
placeholder={`${t("Search collections & documents")}`}
placeholder={
showDocuments
? `${t("Search collections & documents")}`
: `${t("Search collections")}`
}
autoFocus
/>
<ListContainer>
@@ -412,14 +424,12 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
<Flex role="listbox" column>
<List
ref={listRef}
key={nodes.length}
width={width}
height={height}
itemData={nodes}
itemCount={nodes.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results) => results[index].id}
>
{ListItem}
@@ -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,14 +48,13 @@ function DocumentExplorerNode(
onDisclosureClick,
onPointerMove,
onClick,
onDoubleClick,
}: Props,
ref: React.RefObject<HTMLSpanElement>
) {
const { t } = useTranslation();
const OFFSET = 12;
const DISCLOSURE = 20;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
const DISCLOSURE = 24;
const width = (depth + 2) * DISCLOSURE;
return (
<Node
@@ -51,9 +62,11 @@ function DocumentExplorerNode(
selected={selected}
active={active}
onClick={onClick}
onDoubleClick={onDoubleClick}
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
<Spacer width={width}>
{hasChildren && (
@@ -79,7 +92,11 @@ const Title = styled(Text)`
const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin-top: 2px;
margin: 2px 0;
&&[aria-expanded="true"]:not(:hover) {
background: none;
}
`;
const Spacer = styled(Flex)<{ width: number }>`
@@ -1,10 +1,9 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
import { Node as SearchResult } from "./DocumentExplorerNode";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
@@ -18,6 +17,7 @@ type Props = {
onPointerMove: (ev: React.MouseEvent) => void;
onClick: (ev: React.MouseEvent) => void;
onDoubleClick: (ev: React.MouseEvent) => void;
};
function DocumentExplorerSearchResult({
@@ -29,31 +29,20 @@ function DocumentExplorerSearchResult({
path,
onPointerMove,
onClick,
onDoubleClick,
}: Props) {
const { t } = useTranslation();
const ref = React.useCallback(
(node: HTMLSpanElement | null) => {
if (active && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
});
}
},
[active]
);
return (
<SearchResult
ref={ref}
selected={selected}
active={active}
onClick={onClick}
onDoubleClick={onDoubleClick}
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
{icon}
<Flex>
@@ -2,16 +2,15 @@ import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import type { NavigationNode } from "@shared/types";
import { descendants, flattenTree } from "@shared/utils/tree";
import type Document from "~/models/Document";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
document: Document;
@@ -25,13 +24,23 @@ function DocumentMove({ document }: Props) {
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(() => {
// Recursively filter out the document itself and its existing parent doc, if any.
// Collect the IDs of the document itself and all of its descendants so they
// can be excluded from the move targets (moving to self or a descendant
// would create a cycle; moving to the exact same location is a no-op).
const allNodes = collectionTrees.flatMap(flattenTree);
const sourceNode = allNodes.find((node) => node.id === document.id);
const excludedIds = new Set<string>([document.id]);
if (sourceNode) {
descendants(sourceNode).forEach((n) => excludedIds.add(n.id));
}
// Recursively filter out the document itself and its descendants.
// The document's current parent is intentionally kept so that siblings
// remain visible as valid move targets.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
?.filter((c) => !excludedIds.has(c.id))
.map(filterSourceDocument),
});
@@ -44,33 +53,20 @@ function DocumentMove({ document }: Props) {
: true
);
// If the document we're moving is a template, only show collections as
// move targets.
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [
policies,
collectionTrees,
document.id,
document.parentDocumentId,
document.isTemplate,
]);
}, [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 });
@@ -92,7 +88,7 @@ function DocumentMove({ document }: Props) {
<FlexContainer column>
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
@@ -106,8 +102,8 @@ function DocumentMove({ document }: Props) {
) : (
t("Select a location to move")
)}
</StyledText>
<Button disabled={!selectedPath || moving} onClick={move}>
</Text>
<Button disabled={!selectedPath || moving} onClick={() => move()}>
{moving ? `${t("Moving")}` : t("Move")}
</Button>
</Footer>
@@ -115,23 +111,4 @@ function DocumentMove({ document }: Props) {
);
}
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
export const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
export default observer(DocumentMove);
@@ -0,0 +1,86 @@
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import type Template from "~/models/Template";
import Button from "~/components/Button";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
template: Template;
};
function TemplateMove({ template }: Props) {
const { dialogs, policies } = useStores();
const { t } = useTranslation();
const collectionTrees = useCollectionTrees();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(
() =>
collectionTrees
.map((node) => ({ ...node, children: [] }))
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
),
[policies, collectionTrees]
);
const move = async (path = selectedPath) => {
if (!path) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = (path.collectionId ?? path.id) as string;
await template.save({ collectionId });
toast.success(t("Template moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the template, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={move}
onSelect={selectPath}
showDocuments={false}
/>
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={() => move()}>
{t("Move")}
</Button>
</Footer>
</FlexContainer>
);
}
export default observer(TemplateMove);
+3
View File
@@ -0,0 +1,3 @@
import DocumentExplorer from "./DocumentExplorer";
export default DocumentExplorer;
+32 -13
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";
@@ -22,10 +23,12 @@ import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
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";
@@ -39,7 +42,6 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -59,6 +61,7 @@ function DocumentListItem(
const { userMemberships, groupMemberships } = useStores();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isMobile = useMobile();
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
@@ -75,7 +78,6 @@ function DocumentListItem(
showCollection,
showPublished,
showDraft = true,
showTemplate,
highlight,
context,
...rest
@@ -83,7 +85,7 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const canStar = !document.isArchived;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
@@ -98,14 +100,30 @@ 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={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
activeModels: [
document,
...(!isShared && document.collection ? [document.collection] : []),
],
}}
>
<ContextMenu
@@ -115,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),
@@ -162,10 +181,7 @@ function DocumentListItem(
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && <StarButton document={document} />}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
{canStar && !isMobile && <StarButton document={document} />}
</Heading>
{!queryIsInTitle && (
@@ -231,6 +247,7 @@ const Actions = styled(EventBoundary)`
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$isDragging?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
@@ -241,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;
+4 -5
View File
@@ -52,7 +52,6 @@ const DocumentMeta: React.FC<Props> = ({
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -142,7 +141,7 @@ const DocumentMeta: React.FC<Props> = ({
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const canShowProgressBar = isTasks;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -170,7 +169,7 @@ const DocumentMeta: React.FC<Props> = ({
};
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
@@ -219,8 +218,8 @@ const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
const Container = styled(Flex)<{ $rtl?: boolean }>`
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
font-size: 13px;
white-space: nowrap;
+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";
+11 -18
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 });
}
});
@@ -266,7 +260,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<>
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
$rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
@@ -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;
+10 -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";
@@ -88,6 +88,7 @@ function Header(
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
haptic="light"
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
@@ -115,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";
+5 -7
View File
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
white-space: nowrap;
`;
export const Description = styled(StyledText)`
export const Description = styled(StyledText)<{ $margin?: string }>`
${sharedVars}
margin-top: 0.5em;
margin-top: ${(props) => props.$margin ?? "0.5em"};
line-height: var(--line-height);
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
overflow: hidden;
@@ -64,8 +64,6 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
width: fit-content;
border-radius: 2em;
padding: 1px 8px 1px 20px;
margin-right: 0.5em;
margin-top: 0.5em;
position: relative;
flex-shrink: 0;
@@ -75,8 +73,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${(props) =>
props.color || props.theme.backgroundSecondary};
@@ -90,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;
@@ -114,7 +113,6 @@ export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
${(props) =>
props.fadeOut !== false
? `&:after {
${sharedVars}
content: "";
display: block;
position: absolute;
@@ -17,6 +17,7 @@ import HoverPreviewGroup from "./HoverPreviewGroup";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewProject from "./HoverPreviewProject";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 500;
@@ -192,6 +193,18 @@ const HoverPreviewDesktop = observer(
createdAt={data.createdAt}
state={data.state}
/>
) : data.type === UnfurlResourceType.Project ? (
<HoverPreviewProject
ref={cardRef}
url={data.url}
name={data.name}
color={data.color}
lead={data.lead}
labels={data.labels}
description={data.description}
state={data.state}
targetDate={data.targetDate}
/>
) : (
<HoverPreviewLink
ref={cardRef}
@@ -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
@@ -3,9 +3,11 @@ import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { IntegrationService } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
@@ -28,9 +30,11 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
urlObj.hostname === "linear.app"
? IntegrationService.Linear
: urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.GitLab;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -58,9 +62,20 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
{description && (
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Flex wrap>
<Flex wrap gap={6} style={{ marginTop: 8 }}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
@@ -0,0 +1,148 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Backticks } from "@shared/components/Backticks";
import Squircle from "@shared/components/Squircle";
import Editor from "~/components/Editor";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Card,
CardContent,
Label,
Description,
} from "./Components";
import { richExtensions } from "@shared/editor/nodes";
type Props = Pick<
UnfurlResponse[UnfurlResourceType.Project],
| "url"
| "name"
| "color"
| "lead"
| "labels"
| "state"
| "targetDate"
| "description"
>;
const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_(
{ url, name, color, lead, labels, state, description, targetDate }: Props,
ref: React.Ref<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={4} column>
<Title>
<StyledSquircle color={color} size={16} />
<span>
<Backticks content={name} />
</span>
</Title>
{description && (
<Description as="div" $margin="0">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Text
type="tertiary"
size="small"
style={{ textTransform: "capitalize" }}
>
{state.name}
</Text>
{(lead || targetDate) && (
<>
<Divider />
{lead && (
<MetadataRow>
<MetadataLabel>{t("Lead")}</MetadataLabel>
<Flex align="center" gap={6}>
<Avatar src={lead.avatarUrl} size={AvatarSize.Toast} />
<Text size="small">{lead.name}</Text>
</Flex>
</MetadataRow>
)}
{targetDate && (
<MetadataRow>
<MetadataLabel>{t("Target date")}</MetadataLabel>
<Text size="small">
<Time dateTime={targetDate} addSuffix />
</Text>
</MetadataRow>
)}
</>
)}
{labels.length > 0 && (
<>
<Divider />
<MetadataRow>
<MetadataLabel>{t("Labels")}</MetadataLabel>
<Flex wrap gap={6}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
</Label>
))}
</Flex>
</MetadataRow>
</>
)}
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
});
const StyledSquircle = styled(Squircle)`
flex-shrink: 0;
margin-top: 4px;
`;
const Divider = styled.div`
height: 1px;
background: ${s("divider")};
margin: 4px 0;
`;
const MetadataRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
`;
const MetadataLabel = styled(Text).attrs({
type: "tertiary",
size: "small",
})`
flex-shrink: 0;
min-width: 80px;
`;
export default HoverPreviewProject;
@@ -3,8 +3,10 @@ import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
@@ -48,7 +50,18 @@ const HoverPreviewPullRequest = React.forwardRef(
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
{description && (
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
</Flex>
</CardContent>
</Card>
@@ -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);
+102 -7
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;
`;
@@ -95,8 +103,10 @@ const IconWrapper = styled.span`
export const Outline = styled(Flex)<{
margin?: string | number;
hasError?: boolean;
focused?: boolean;
$focused?: boolean;
$round?: boolean;
}>`
position: relative;
flex: 1;
margin: ${(props) =>
props.margin !== undefined ? props.margin : "0 0 16px"};
@@ -106,10 +116,10 @@ export const Outline = styled(Flex)<{
border-color: ${(props) =>
props.hasError
? props.theme.danger
: props.focused
: 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";
+10 -8
View File
@@ -1,3 +1,4 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet-async";
@@ -7,6 +8,7 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import { useRightSidebarContent } from "~/components/RightSidebarContext";
import SkipNavContent from "~/components/SkipNavContent";
import SkipNavLink from "~/components/SkipNavLink";
import env from "~/env";
@@ -19,16 +21,15 @@ type Props = {
title?: string;
/** Left sidebar content. */
sidebar?: React.ReactNode;
/** Right sidebar content. */
sidebarRight?: React.ReactNode;
};
const Layout = React.forwardRef(function Layout_(
{ title, children, sidebar, sidebarRight }: Props,
{ title, children, sidebar }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
const sidebarRight = useRightSidebarContent();
return (
<Container column auto ref={ref}>
@@ -47,6 +48,7 @@ const Layout = React.forwardRef(function Layout_(
<Content
auto
justify="center"
role="main"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
@@ -54,14 +56,14 @@ const Layout = React.forwardRef(function Layout_(
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
marginInlineStart: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
{sidebarRight}
<AnimatePresence initial={false}>{sidebarRight}</AnimatePresence>
</Container>
</Container>
);
@@ -84,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;`}
`};
`;
+23 -17
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 }>;
@@ -9,39 +10,44 @@ export interface LazyComponent<T extends React.ComponentType<any>> {
interface LazyLoadOptions {
retries?: number;
interval?: number;
/** If provided, picks this named export from the module instead of `default`. */
exportName?: string;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
* Supports both default and named exports.
*
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
* @param factory A function that returns a promise of a module.
* @param options Optional configuration for retry behavior and export name.
* @returns An object containing the lazy Component and a preload function.
*
* @example
* ```typescript
* // Default export
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* // Named export
* const MyComponent = createLazyComponent(() => import('./MyComponent'), {
* exportName: 'MyComponent',
* });
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
factory: () => Promise<Record<string, T>>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval } = options;
const { retries, interval, exportName } = options;
const wrappedFactory = exportName
? () =>
factory().then((m) => ({
default: m[exportName],
}))
: (factory as () => Promise<{ default: T }>);
return {
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
Component: lazyWithRetry(wrappedFactory, retries, interval),
preload: wrappedFactory,
};
}
+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;

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