Compare commits

..

47 Commits

Author SHA1 Message Date
Tom Moor 6863fe3698 Merge branch 'main' into codegen-bot/implement-mention-model-tracking-9268 2025-12-12 19:47:16 -05:00
Apoorv Mishra 948e557bdd Utilize GitHub integration to fetch information about public issues/PRs (#10827)
* fix: use github APIs to unfurl public gh issues/prs

* fix: revert

* fix: multiple gh accounts

* fix: use replacements
2025-12-12 19:05:14 -05:00
Copilot d5dbf286cc Add missing database indexes for hooks.unfurl endpoint (#10870)
* Initial plan

* Add database indexes to improve hooks.unfurl performance

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

* Verify migrations and query plans for new indexes

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

* Address code review feedback: improve migration rollback order and add comments

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

* Change index column order to teamId first as requested

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

* Update .env.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>
2025-12-12 19:02:20 -05:00
Tom Moor 27f4ba7062 perf: Reorder policy checks (#10874)
* Reorder document policy checks

* Reorder collection policy checks
2025-12-12 18:58:23 -05:00
Tom Moor c3ffcd8d38 Update documents.ts (#10873) 2025-12-12 18:46:08 -05:00
Copilot b68997f78a Fix MentionsProcessor tests: implement parseMentions and resolve circular dependency (#10866)
* Initial plan

* Fix circular dependency in Mention model by using lazy import for can function

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

* Implement parseMentions method and fix test markdown format for mentions

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>
2025-12-12 07:20:41 -05:00
Tom Moor e19b23c22f perf: Remove serialization of tasks for public API responses (#10864) 2025-12-11 22:40:26 -05:00
Tom Moor 2e471f88be perf: Policy evaluation (#10863)
* perf: Several O(n) improvements in policy calculation

* perf: Simplify to single loop in can method

* perf: refactor ability lookups
2025-12-11 20:51:22 -05:00
Tom Moor 8cb07889ce perf: Further break up popularity batch querying (#10862) 2025-12-11 18:58:18 -05:00
Apoorv Mishra f8a79f9e79 Bring back notice menu (#10860)
* fix: notice menu regression

* fix: local var
2025-12-11 10:51:19 -05:00
Tom Moor 1e894aabdf fix: Query not forwarded on internal links (#10854)
closes #10853
2025-12-11 02:17:12 +00:00
Tom Moor 6cd2346d46 fix: Media editor crashes page (#10852)
closes #10851
2025-12-11 00:43:19 +00:00
Tom Moor 35510fb4be fix: Ignore missing .env in bootstrap.ts (#10848) 2025-12-10 17:14:39 -05:00
Tom Moor ac460318fd fix: Quick fix for selection behavior (#10845) 2025-12-10 14:24:55 +00:00
Tom Moor 6b3900cfc5 fix: Code language picker missing (#10844) 2025-12-10 08:35:35 -05:00
Tom Moor 2543d6d56c fix: Code words should wrap on mobile (#10842) 2025-12-09 22:05:47 -05:00
Apoorv Mishra 5140d2434e fix: apply react/rules-of-hooks (#10840) 2025-12-09 18:53:49 -05:00
dependabot[bot] 108e14338b chore(deps): bump the aws group with 5 updates (#10830)
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.927.0` | `3.946.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.927.0` | `3.946.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.927.0` | `3.946.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.927.0` | `3.946.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.927.0` | `3.946.0` |


Updates `@aws-sdk/client-s3` from 3.927.0 to 3.946.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.946.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.927.0 to 3.946.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.946.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.927.0 to 3.946.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.946.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.927.0 to 3.946.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.946.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.927.0 to 3.946.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.946.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 17:15:22 -05:00
dependabot[bot] df284756f1 chore(deps): bump prosemirror-inputrules from 1.5.0 to 1.5.1 (#10831)
Bumps [prosemirror-inputrules](https://github.com/prosemirror/prosemirror-inputrules) from 1.5.0 to 1.5.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-inputrules/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-inputrules/compare/1.5.0...1.5.1)

---
updated-dependencies:
- dependency-name: prosemirror-inputrules
  dependency-version: 1.5.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>
2025-12-08 17:15:09 -05:00
dependabot[bot] 410c196943 chore(deps-dev): bump discord-api-types from 0.38.30 to 0.38.36 (#10832)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.38.30 to 0.38.36.
- [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.30...0.38.36)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 17:14:45 -05:00
dependabot[bot] 4893c61a1f chore(deps-dev): bump ioredis-mock from 8.9.0 to 8.13.1 (#10833)
Bumps [ioredis-mock](https://github.com/stipsan/ioredis-mock) from 8.9.0 to 8.13.1.
- [Release notes](https://github.com/stipsan/ioredis-mock/releases)
- [Changelog](https://github.com/stipsan/ioredis-mock/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stipsan/ioredis-mock/compare/v8.9.0...v8.13.1)

---
updated-dependencies:
- dependency-name: ioredis-mock
  dependency-version: 8.13.1
  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>
2025-12-08 17:14:35 -05:00
dependabot[bot] 6e2793a751 chore(deps): bump form-data from 4.0.4 to 4.0.5 (#10834)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.4 to 4.0.5.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.4...v4.0.5)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.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>
2025-12-08 17:14:26 -05:00
Salihu 5e5b37c418 only show "show more" when not loading (#10837) 2025-12-08 17:14:07 -05:00
Tom Moor e30c35acdb fix: Another spot rules of hooks were broken (#10836) 2025-12-08 19:27:40 +00:00
Tom Moor c3ad5bb7f6 fix: Rules of hooks error (#10820) 2025-12-07 11:29:02 +00:00
Tom Moor 971c542613 fix: "Edit" button for collection overview (#10816)
* refactor

* working

* collectionPath refactor

* fix: Flush debounced save

* Move to Actions component

* Keyboard shortcuts

* PR feedback
2025-12-06 18:02:58 -05:00
Tom Moor 3681d1c9b2 fix: Fetch emoji name if necessary (#10819)
* fix: Fetch emoji name if neccessary

* Avoid re-render

* fix: Minor layout issues in emoji dialog picker

* Add upload button to empty search results
2025-12-06 18:45:40 +00:00
Tom Moor 621409ae0b fix: Ensure shutdown with db migration lock correctly releases (#10817)
* fix: Ensure unsafe shutdown with db migration lock correctly releases

* Update server/utils/MutexLock.ts

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-06 13:43:00 -05:00
Apoorv Mishra 8af6fdcc4f Backlink to toolbar menu (#10762)
* fix: port link related commands to work for image selection

* fix: selection

* fix: click img to open link

* fix: hover preview for image with link

* cleanup: hasLink not needed

* fix: we've img wrapped in an `a` tag now, so this is no more required

* fix: cover all edge cases

* fix: cleanup

* fix: zoom in action button in edit mode

* fix: separator div instead of gap

* fix: toolbar refactor

* fix: back button press

* fix: import

* fix: revert

* fix: enum

* fix: onClick on item

* fix: selection at end after link

* fix: show linkbar if link present

* fix: ReturnIcon

* fix: onClickBack

* fix: TOOLBAR -> Toolbar

* fix: show zoom in icon even when selected

* fix: isInlineMarkActive

* fix: jsdoc

* yarn.lock

* Revert "yarn.lock"

This reverts commit 5f44e5e017.

* fix: yarn.lock

* fix: link editor closes upon zoom in click action

* Update shared/editor/queries/isMarkActive.ts

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

* Update shared/editor/queries/getMarkRange.ts

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

* Update shared/editor/components/Image.tsx

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

* Update app/editor/components/LinkEditor.tsx

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

* fix: prevent toolbar state reset

* fix: tooltip

* fix: copilot

* fix: i18n, misuse of attrs

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-12-06 12:21:35 -05:00
Tom Moor ccbc7779ea feat: Add fade variant of Scrollable (#10814) 2025-12-06 09:47:10 -05:00
Tom Moor 2eeeae4a7c perf: Table decorations (#10812)
* perf: CodeFence decos

* perf: Caching for table decos

* PR feedback
2025-12-06 12:08:22 +00:00
Tom Moor 050499b8fc chore: Convert dashes to underscores instead of removing (#10811) 2025-12-06 03:54:26 +00:00
Tom Moor 7d88c97914 fix: Heading caret positioning (#10810)
* fix: Avoid decoration

* perf
2025-12-06 03:49:43 +00:00
Tom Moor e82c848051 feat: Add upload button in emoji picker (#10809) 2025-12-05 20:52:59 -05:00
Salihu 4b6c6f7b36 feat: Distribute table columns evenly (#10645)
* space columns evenly feature

* more accurate col width

* distribute table width more accurately

* minor fix

* code cleanup

* minor fixes

* minor fix

* adjust icon

* language, remove font awesome usage

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-12-06 01:25:04 +00:00
Tom Moor d795e78b79 fix: Shared document root node alignment (#10808)
closes #10800
2025-12-06 01:09:30 +00:00
Tom Moor 5b3d6c3535 fix: Use XMLSerializer to extract valid XML (#10807) 2025-12-06 01:02:07 +00:00
Tom Moor 6f3534c713 feat: Custom emoji reactions (#10805)
* Claude first pass

* Move custom emojis first in search results

* refactor

* fix: Remove extra load emoji call
2025-12-05 18:47:12 -05:00
Copilot 133ec073be Add CSV export for member list (#10803)
* Initial plan

* Add CSV export functionality to members page

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

* Align Export CSV button to the right

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

* Address code review feedback: improve type safety, error handling, and date formatting

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

* Improve CSV utility and date handling consistency

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

* Improve error messages and fix useCallback dependencies

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

* Add comprehensive tests for CSV utility

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

* Refactor: reduce limit to 100, replace lastActiveIp with role, extract ExportCSV component

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

* Improve type safety and extract pagination constant

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>
2025-12-05 09:42:36 -05:00
Tom Moor 305b81fbf4 fix: Badges should never wrap (#10804) 2025-12-05 13:43:22 +00:00
Tom Moor aeb777b2f5 fix: Shift paste inserts next to selection (#10799) 2025-12-04 23:11:33 -05:00
Tom Moor f307c678c2 feat: Support user initials in mention search (#10797)
* feat: Support user initials in mention search

* test
2025-12-05 03:32:29 +00:00
Tom Moor ac23277b7c fix: Find and replace positioning (#10795) 2025-12-05 03:19:48 +00:00
Copilot eee64e363f Skip compression for GIF emoji uploads to preserve animation (#10792)
* Initial plan

* Skip compression for GIF files to preserve animation

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

* Simplify type assertions in tests

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

* Remove unnecessary test file

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>
2025-12-05 02:45:39 +00:00
Copilot 55116b4761 Fix template insertion to use cursor position instead of document start (#10783)
* Initial plan

* Fix template insertion to use current cursor position

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

* Apply prettier formatting

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>
2025-12-04 21:45:18 -05:00
codegen-sh[bot] ac2d3bf3cb Fix TypeScript compilation errors in Mention model
- Replace authorize() with can() function for proper boolean return
- Add missing createdAt property to documents.update events in tests
- Add non-null assertions for required properties
- Fix event type definitions to match DocumentEvent interface
2025-05-31 00:17:15 +00:00
codegen-sh[bot] 94a8326c68 Implement Mention model for tracking user mentions
- Add Mention model with relationships to User and Document
- Create database migration for mentions table
- Add MentionsProcessor to handle document events
- Add comprehensive tests for MentionsProcessor
- Update model relationships in Document and User models

Addresses issue #9268 for tracking user mentions similar to backlinks
2025-05-30 23:53:38 +00:00
129 changed files with 4303 additions and 2634 deletions
+5
View File
@@ -1,6 +1,10 @@
NODE_ENV=test
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET=test-utils-secret-key
REDIS_URL=redis://localhost:6379
URL=http://localhost:3000
COLLABORATION_URL=http://localhost:3001
SMTP_HOST=smtp.example.com
SMTP_USERNAME=test
@@ -12,6 +16,7 @@ GOOGLE_CLIENT_SECRET=123
SLACK_CLIENT_ID=123
SLACK_CLIENT_SECRET=123
SLACK_VERIFICATION_TOKEN=test-token-123
GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
+2 -1
View File
@@ -94,7 +94,8 @@
"args": "after-used",
"ignoreRestSiblings": true
}
]
],
"react/rules-of-hooks": "error"
},
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
}
-123
View File
@@ -1,123 +0,0 @@
import { ArchiveIcon, MoveIcon, TrashIcon } from "outline-icons";
import DocumentMove from "~/scenes/DocumentMove";
import { createAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentArchive from "~/scenes/DocumentArchive";
import Document from "~/models/Document";
type Props = {
documents: Document[];
};
/**
* Archive multiple documents at once.
*/
export const bulkArchiveDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Bulk archive documents",
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).archive);
},
perform: async ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Archive {{ count }} documents", { count }),
content: (
<DocumentArchive
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
/**
* Move multiple documents at once.
*/
export const bulkMoveDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Move")}`,
analyticsName: "Bulk move documents",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).move);
},
perform: ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Move {{ count }} documents", { count }),
content: (
<DocumentMove
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
/**
* Delete multiple documents at once.
*/
export const bulkDeleteDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Bulk delete documents",
section: ActiveDocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).delete);
},
perform: async ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Delete {{ count }} documents", { count }),
content: (
<DocumentDelete
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
export const rootBulkDocumentActions = [
bulkArchiveDocuments,
bulkMoveDocuments,
bulkDeleteDocuments,
];
+21 -4
View File
@@ -47,6 +47,7 @@ import DocumentMove from "~/scenes/DocumentMove";
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 MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -80,7 +81,6 @@ import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Action, ActionGroup, ActionSeparator } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentArchive from "~/scenes/DocumentArchive";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -1028,7 +1028,7 @@ export const moveDocumentToCollection = createAction({
title: t("Move {{ documentType }}", {
documentType: document.noun,
}),
content: <DocumentMove documents={[document]} />,
content: <DocumentMove document={document} />,
});
}
},
@@ -1094,7 +1094,19 @@ export const archiveDocument = createAction({
dialogs.openModal({
title: t("Are you sure you want to archive this document?"),
content: <DocumentArchive documents={[document]} />,
content: (
<ConfirmationDialog
onSubmit={async () => {
await document.archive();
toast.success(t("Document archived"));
}}
savingText={`${t("Archiving")}`}
>
{t(
"Archiving this document will remove it from the collection and search results."
)}
</ConfirmationDialog>
),
});
}
},
@@ -1218,7 +1230,12 @@ export const deleteDocument = createAction({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
content: <DocumentDelete documents={[document]} />,
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
+1 -1
View File
@@ -21,7 +21,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function _ActionButton(
function ActionButton_(
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
+1
View File
@@ -21,6 +21,7 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
font-size: 12px;
font-weight: 500;
user-select: none;
white-space: nowrap;
`;
export default Badge;
-108
View File
@@ -1,108 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import {
MenuHeader,
MenuSeparator,
} from "~/components/primitives/components/Menu";
import { Portal } from "~/components/Portal";
import { toMobileMenuItems } from "~/components/Menu/transformer";
import { actionToMenuItem } from "~/actions";
import { useBulkDocumentMenuAction } from "~/hooks/useBulkDocumentMenuAction";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import { ActionVariant } from "~/types";
import NudeButton from "./NudeButton";
import { CrossIcon } from "outline-icons";
function BulkSelectionToolbar() {
const { t } = useTranslation();
const { documents, ui } = useStores();
const selectedCount = documents.selectedDocumentIds.length;
const selectedDocuments = documents.selectedDocuments;
const sidebarWidth = ui.sidebarWidth;
const handleClearSelection = React.useCallback(() => {
documents.clearSelection();
}, [documents]);
const rootAction = useBulkDocumentMenuAction({
documents: selectedDocuments,
});
const actionContext = useActionContext({
isMenu: true,
});
const menuItems = React.useMemo(() => {
if (!rootAction.children || selectedCount === 0) {
return [];
}
return (rootAction.children as ActionVariant[]).map((childAction) =>
actionToMenuItem(childAction, actionContext)
);
}, [rootAction.children, selectedCount, actionContext]);
const content = toMobileMenuItems(menuItems, handleClearSelection, () => {});
if (selectedCount === 0) {
return null;
}
return (
<Portal>
<Wrapper $sidebarWidth={sidebarWidth}>
<MenuContainer>
<Header>
<MenuHeader>
{t("{{ count }} selected", { count: selectedCount })}
</MenuHeader>
<ClearButton
onClick={handleClearSelection}
tooltip={{
content: t("Clear selection"),
}}
>
<CrossIcon size={18} />
</ClearButton>
</Header>
<MenuSeparator />
{content}
</MenuContainer>
</Wrapper>
</Portal>
);
}
const ClearButton = styled(NudeButton)`
&:hover {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const Wrapper = styled.div<{ $sidebarWidth: number }>`
position: fixed;
bottom: 24px;
left: ${(props) => props.$sidebarWidth + 16}px;
z-index: ${depths.menu};
`;
const MenuContainer = styled.div`
min-width: 180px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
`;
export default observer(BulkSelectionToolbar);
+1 -1
View File
@@ -28,7 +28,7 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
to: collectionPath(collection),
}),
],
[collection, t]
+1 -1
View File
@@ -31,7 +31,7 @@ export type RefHandle = {
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
*/
const ContentEditable = React.forwardRef(function _ContentEditable(
const ContentEditable = React.forwardRef(function ContentEditable_(
{
disabled,
onChange,
+29 -23
View File
@@ -8,7 +8,7 @@ import { AttachmentPreset } from "@shared/types";
import { getDataTransferFiles } from "@shared/utils/files";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Input, { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
@@ -108,12 +108,16 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
setIsUploading(true);
try {
const compressed = await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
// 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(compressed, {
const attachment = await uploadFile(fileToUpload, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
@@ -147,26 +151,11 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
>
<Text as="p" type="secondary">
{t(
"The emoji name should be unique and contain only lowercase letters, numbers, and underscores."
"Square images with transparent backgrounds work best. If your image is too large, well try to resize it for you."
)}
</Text>
<Input
label={t("Name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
<LabelText as="label">{t("Upload an image")}</LabelText>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<Flex column align="center" gap={8}>
@@ -197,6 +186,22 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
</Flex>
</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>
@@ -213,6 +218,7 @@ const DropZone = styled.div`
text-align: center;
cursor: var(--pointer);
transition: border-color 0.2s;
margin-bottom: 1em;
&:hover {
border-color: ${s("inputBorderFocused")};
@@ -15,7 +15,7 @@ import {
type Props = Omit<UnfurlResponse[UnfurlResourceType.Document], "type">;
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
const HoverPreviewDocument = React.forwardRef(function HoverPreviewDocument_(
{ url, id, title, summary, lastActivityByViewer }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -17,7 +17,7 @@ import ErrorBoundary from "../ErrorBoundary";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
const HoverPreviewGroup = React.forwardRef(function HoverPreviewGroup_(
{ name, description, memberCount, users }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -24,7 +24,7 @@ import {
type Props = Omit<UnfurlResponse[UnfurlResourceType.Issue], "type">;
const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
{ url, id, title, description, author, labels, state, createdAt }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -20,7 +20,7 @@ type Props = {
description: string;
};
const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
const HoverPreviewLink = React.forwardRef(function HoverPreviewLink_(
{ url, thumbnailUrl, title, description }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -6,7 +6,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
const HoverPreviewMention = React.forwardRef(function HoverPreviewMention_(
{ avatarUrl, name, lastActive, color }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -20,7 +20,7 @@ import {
type Props = Omit<UnfurlResponse[UnfurlResourceType.PR], "type">;
const HoverPreviewPullRequest = React.forwardRef(
function _HoverPreviewPullRequest(
function HoverPreviewPullRequest_(
{ url, title, id, description, author, state, createdAt }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -9,4 +9,5 @@ export const UserInputContainer = styled(Flex)`
export const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
min-width: 0;
`;
@@ -1,170 +0,0 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Flex from "~/components/Flex";
import useStores from "~/hooks/useStores";
import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
import { IconType } from "@shared/types";
import { DisplayCategory } from "../utils";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
import Emoji from "~/models/Emoji";
const GRID_HEIGHT = 410;
type Props = {
panelWidth: number;
height?: number;
query: string;
panelActive: boolean;
onEmojiChange: (emoji: string) => void;
onQueryChange: (query: string) => void;
};
const CustomEmojiPanel = ({
query,
panelActive,
panelWidth,
height = GRID_HEIGHT,
onEmojiChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const [searchData, setSearchData] = useState<DataNode[]>([]);
const [freqEmojis, setFreqEmojis] = useState<EmojiNode[]>([]);
const { getFrequentIcons, incrementIconCount } = useIconState(
IconType.Custom
);
const { emojis } = useStores();
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
useEffect(() => {
if (query.trim()) {
const initialData = emojis.findByQuery(query);
if (initialData.length) {
setSearchData([
{
category: DisplayCategory.Search,
icons: initialData?.map(toIcon),
},
]);
}
emojis
.fetchAll({
query,
})
.then((data) => {
if (data.length) {
const iconMap = new Map([
...initialData.map((emoji): [string, EmojiNode] => [
emoji.name,
toIcon(emoji),
]),
...data.map((emoji): [string, EmojiNode] => [
emoji.name,
toIcon(emoji),
]),
]);
setSearchData([
{
category: DisplayCategory.Search,
icons: Array.from(iconMap.values()),
},
]);
return;
}
setSearchData([]);
});
} else {
setSearchData([]);
}
}, [query, emojis]);
useEffect(() => {
getFrequentIcons().forEach((id) => {
emojis
.fetch(id)
.then((emoji) => {
setFreqEmojis((prev) => {
if (prev.some((item) => item.id === id)) {
return prev;
}
return [...prev, toIcon(emoji)];
});
})
.catch(() => {
// ignore
});
});
}, [getFrequentIcons, emojis]);
const handleEmojiSelection = React.useCallback(
({ id }: { id: string }) => {
onEmojiChange(id);
incrementIconCount(id);
},
[onEmojiChange, incrementIconCount]
);
const templateData: DataNode[] = React.useMemo(
() => [
{
category: DisplayCategory.Frequent,
icons: freqEmojis,
},
{
category: DisplayCategory.All,
icons: emojis.orderedData.map(toIcon),
},
],
[emojis.orderedData, freqEmojis]
);
React.useLayoutEffect(() => {
if (!panelActive) {
return;
}
scrollableRef.current?.scroll({ top: 0 });
requestAnimationFrame(() => searchRef.current?.focus());
}, [panelActive]);
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search")}`}
onChange={handleFilter}
/>
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={height - 48}
data={searchData.length ? searchData : templateData}
onIconSelect={handleEmojiSelection}
/>
</Flex>
);
};
const toIcon = (emoji: Emoji): EmojiNode => ({
type: IconType.Custom,
id: emoji.id,
value: emoji.id,
name: emoji.name,
});
export default CustomEmojiPanel;
@@ -1,14 +1,23 @@
import concat from "lodash/concat";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
import { DisplayCategory } from "../utils";
import GridTemplate, { DataNode } from "./GridTemplate";
import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
import useStores from "~/hooks/useStores";
import Emoji from "~/models/Emoji";
import { useComputed } from "~/hooks/useComputed";
import { MenuButton } from "./MenuButton";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import { IconButton } from "./IconButton";
const GRID_HEIGHT = 410;
@@ -30,9 +39,15 @@ const EmojiPanel = ({
height = GRID_HEIGHT,
}: Props) => {
const { t } = useTranslation();
const { emojis, dialogs } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const customEmojis = useComputed(
() => emojis.orderedData.map(toIcon),
[emojis.orderedData]
);
const {
emojiSkinTone: skinTone,
@@ -41,11 +56,20 @@ const EmojiPanel = ({
getFrequentIcons,
} = useIconState(IconType.Emoji);
const {
incrementIconCount: incrementCustomIconCount,
getFrequentIcons: getFrequentCustomIcons,
} = useIconState(IconType.Custom);
const freqEmojis = React.useMemo(
() => getFrequentIcons(),
[getFrequentIcons]
);
const [freqCustomEmojis, setFreqCustomEmojis] = React.useState<EmojiNode[]>(
[]
);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
@@ -60,23 +84,68 @@ const EmojiPanel = ({
[setEmojiSkinTone]
);
const handleUploadClick = React.useCallback(() => {
dialogs.openModal({
title: t("Upload emoji"),
content: <EmojiCreateDialog onSubmit={dialogs.closeAllModals} />,
});
}, [dialogs, t]);
const handleEmojiSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onEmojiChange(value);
incrementIconCount(id);
// Determine if this is a custom emoji by checking if it's in the custom emoji data
const isCustomEmoji =
customEmojis.some((emoji) => emoji.id === id) ||
freqCustomEmojis.some((emoji) => emoji.id === id);
if (isCustomEmoji) {
incrementCustomIconCount(id);
} else {
incrementIconCount(id);
}
},
[onEmojiChange, incrementIconCount]
[
onEmojiChange,
incrementIconCount,
incrementCustomIconCount,
customEmojis,
freqCustomEmojis,
]
);
React.useEffect(() => {
// Load frequent custom emojis
getFrequentCustomIcons().forEach((id) => {
emojis
.fetch(id)
.then((emoji) => {
setFreqCustomEmojis((prev) => {
if (prev.some((item) => item.id === id)) {
return prev;
}
return [...prev, toIcon(emoji)];
});
})
.catch(() => {
// ignore
});
});
}, [emojis, getFrequentCustomIcons]);
const isSearch = query !== "";
const templateData: DataNode[] = isSearch
? getSearchResults({
query,
skinTone,
customEmojis,
})
: getAllEmojis({
skinTone,
freqEmojis,
customEmojis,
freqCustomEmojis,
});
React.useLayoutEffect(() => {
@@ -89,7 +158,7 @@ const EmojiPanel = ({
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<UserInputContainer align="center" gap={8}>
<StyledInputSearch
ref={searchRef}
value={query}
@@ -97,6 +166,14 @@ const EmojiPanel = ({
onChange={handleFilter}
/>
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
{can.update && (
<MenuButton
onClick={handleUploadClick}
aria-label={t("Upload emoji")}
>
<PlusIcon />
</MenuButton>
)}
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
@@ -104,6 +181,11 @@ const EmojiPanel = ({
height={height - 48}
data={templateData}
onIconSelect={handleEmojiSelection}
empty={
<IconButton onClick={handleUploadClick}>
<PlusIcon />
</IconButton>
}
/>
</Flex>
);
@@ -112,19 +194,32 @@ const EmojiPanel = ({
const getSearchResults = ({
query,
skinTone,
customEmojis,
}: {
query: string;
skinTone: EmojiSkinTone;
customEmojis: EmojiNode[];
}): DataNode[] => {
const emojis = search({ query, skinTone });
// Search custom emojis by name
const matchingCustomEmojis = customEmojis.filter((emoji) =>
emoji.name?.toLowerCase().includes(query.toLowerCase())
);
const allResults = [
...matchingCustomEmojis,
...emojis.map((emoji) => ({
type: IconType.Emoji as const,
id: emoji.id,
value: emoji.value,
})),
];
return [
{
category: DisplayCategory.Search,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
icons: allResults,
},
];
};
@@ -132,21 +227,32 @@ const getSearchResults = ({
const getAllEmojis = ({
skinTone,
freqEmojis,
customEmojis,
freqCustomEmojis,
}: {
skinTone: EmojiSkinTone;
freqEmojis: string[];
customEmojis: EmojiNode[];
freqCustomEmojis: EmojiNode[];
}): DataNode[] => {
const emojisWithCategory = getEmojisWithCategory({ skinTone });
const getFrequentIcons = (): DataNode => {
const emojis = getEmojis({ ids: freqEmojis, skinTone });
return {
category: DisplayCategory.Frequent,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
// Combine frequent standard and custom emojis
const allFrequent = [
...emojis.map((emoji) => ({
type: IconType.Emoji as const,
id: emoji.id,
value: emoji.value,
})),
...freqCustomEmojis,
];
return {
category: DisplayCategory.Frequent,
icons: allFrequent,
};
};
@@ -162,7 +268,7 @@ const getAllEmojis = ({
};
};
return concat(
const allData = concat(
getFrequentIcons(),
getCategoryData(EmojiCategory.People),
getCategoryData(EmojiCategory.Nature),
@@ -173,6 +279,22 @@ const getAllEmojis = ({
getCategoryData(EmojiCategory.Symbols),
getCategoryData(EmojiCategory.Flags)
);
if (customEmojis.length) {
allData.push({
category: "Custom",
icons: customEmojis,
});
}
return allData;
};
const toIcon = (emoji: Emoji): EmojiNode => ({
type: IconType.Custom,
id: emoji.id,
value: emoji.id,
name: emoji.name,
});
export default EmojiPanel;
@@ -37,14 +37,20 @@ export type DataNode = {
};
type Props = {
/** Width of the grid container */
width: number;
/** Height of the grid container */
height: number;
/** Data to be displayed in the grid */
data: DataNode[];
/** Content to display when search results are empty */
empty?: React.ReactNode;
/** Callback when an icon is selected */
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
};
const GridTemplate = (
{ width, height, data, onIconSelect }: Props,
{ width, height, data, empty, onIconSelect }: Props,
ref: React.Ref<HTMLDivElement>
) => {
// 24px padding for the Grid Container
@@ -52,10 +58,6 @@ const GridTemplate = (
const gridItems = compact(
data.flatMap((node) => {
if (node.icons.length === 0) {
return [];
}
const category = (
<CategoryName
key={node.category}
@@ -67,6 +69,13 @@ const GridTemplate = (
</CategoryName>
);
if (node.icons.length === 0) {
if (node.category !== "Search") {
return [];
}
return [[category], [empty]];
}
const items = node.icons.map((item) => {
if (item.type === IconType.SVG) {
return (
@@ -0,0 +1,19 @@
import { hover, s } from "@shared/styles";
import styled from "styled-components";
import NudeButton from "~/components/NudeButton";
export const MenuButton = styled(NudeButton)`
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
color: ${s("textSecondary")};
border: 1px solid ${s("inputBorder")};
padding: 4px;
&: ${hover} {
border: 1px solid ${s("inputBorderFocused")};
}
`;
@@ -1,18 +1,17 @@
import { useMemo, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/primitives/Popover";
import { IconButton } from "./IconButton";
import { MenuButton } from "./MenuButton";
const SkinTonePicker = ({
skinTone,
@@ -57,9 +56,9 @@ const SkinTonePicker = ({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<StyledMenuButton aria-label={t("Choose default skin tone")}>
<MenuButton aria-label={t("Choose default skin tone")}>
{handEmojiVariants[skinTone]?.value}
</StyledMenuButton>
</MenuButton>
</PopoverTrigger>
<PopoverContent
side="bottom"
@@ -79,15 +78,4 @@ const Emojis = styled(Flex)`
padding: 0 8px;
`;
const StyledMenuButton = styled(NudeButton)`
width: 32px;
height: 32px;
border: 1px solid ${s("inputBorder")};
padding: 4px;
&: ${hover} {
border: 1px solid ${s("inputBorderFocused")};
}
`;
export default SkinTonePicker;
+1 -19
View File
@@ -21,13 +21,11 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
import CustomEmojiPanel from "./components/CustomEmojiPanel";
import useStores from "~/hooks/useStores";
const TAB_NAMES = {
Icon: "icon",
Emoji: "emoji",
Custom: "custom",
} as const;
type TabName = (typeof TAB_NAMES)[keyof typeof TAB_NAMES];
@@ -175,7 +173,7 @@ const IconPicker = ({
if (open) {
void emojis.fetchAll();
}
}, [open]);
}, [open, emojis]);
if (isMobile) {
return (
@@ -254,13 +252,6 @@ const Content = ({
>
{t("Emojis")}
</StyledTab>
<StyledTab
value={TAB_NAMES["Custom"]}
aria-label={t("Custom Emojis")}
$active={activeTab === TAB_NAMES["Custom"]}
>
{t("Custom")}
</StyledTab>
</Tabs.List>
{allowDelete && (
<RemoveButton onClick={onIconRemove}>{t("Remove")}</RemoveButton>
@@ -287,15 +278,6 @@ const Content = ({
onQueryChange={onQueryChange}
/>
</StyledTabContent>
<StyledTabContent value={TAB_NAMES["Custom"]}>
<CustomEmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={open && activeTab === TAB_NAMES["Custom"]}
onEmojiChange={onIconChange}
onQueryChange={onQueryChange}
/>
</StyledTabContent>
</Tabs.Root>
);
};
+1 -1
View File
@@ -904,7 +904,7 @@ type ImageProps = {
onMaxZoom: () => void;
};
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
const Image = forwardRef<HTMLImageElement, ImageProps>(function Image_(
{
src,
alt,
+1 -1
View File
@@ -9,7 +9,7 @@ type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
};
export const OneTimePasswordInput = React.forwardRef(
function _OneTimePasswordInput(
function OneTimePasswordInput_(
{ length = 6, ...rest }: Props,
ref: React.RefObject<HTMLInputElement>
) {
+22 -3
View File
@@ -13,6 +13,9 @@ import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import { isUUID } from "validator";
import { CustomEmoji } from "@shared/components/CustomEmoji";
import useStores from "~/hooks/useStores";
type Props = {
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
@@ -39,13 +42,25 @@ const useTooltipContent = ({
active: boolean;
}) => {
const { t } = useTranslation();
const { emojis } = useStores();
const customEmoji = emojis.get(emoji);
const [transformedEmoji, setTransformedEmoji] = React.useState(
customEmoji?.shortName ?? `:${getEmojiId(emoji)}:`
);
// If the emoji is a custom emoji ID, we need to get its short name for display
if (isUUID(emoji)) {
emojis.fetch(emoji).then((ce) => {
if (ce) {
setTransformedEmoji(ce.shortName);
}
});
}
if (!reactedUsers.length) {
return;
}
const transformedEmoji = `:${getEmojiId(emoji)}:`;
switch (reactedUsers.length) {
case 1: {
return t("{{ username }} reacted with {{ emoji }}", {
@@ -120,7 +135,11 @@ const Reaction: React.FC<Props> = ({
() => (
<EmojiButton disabled={disabled} $active={active} onClick={handleClick}>
<Flex gap={6} justify="center" align="center">
<Emoji size={15}>{reaction.emoji}</Emoji>
{isUUID(reaction.emoji) ? (
<CustomEmoji size={15} value={reaction.emoji} />
) : (
<Emoji size={15}>{reaction.emoji}</Emoji>
)}
<Count weight="xbold">{reaction.userIds.length}</Count>
</Flex>
</EmojiButton>
+65 -7
View File
@@ -1,27 +1,43 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import styled, { css } from "styled-components";
import { hideScrollbars } from "@shared/styles";
import useWindowSize from "~/hooks/useWindowSize";
type Props = React.HTMLAttributes<HTMLDivElement> & {
/** Whether to show shadows at top and bottom when scrolled */
shadow?: boolean;
/** Whether to show shadow at the top when scrolled */
topShadow?: boolean;
/** Whether to show shadow at the bottom when scrolled */
bottomShadow?: boolean;
/** Whether to hide the scrollbars */
hiddenScrollbars?: boolean;
/** Color to fade to (enables fade effect) */
fadeTo?: string;
/** Whether to use flexbox layout */
flex?: boolean;
/** Custom overflow style */
overflow?: string;
children: React.ReactNode;
};
/**
* A scrollable container component with optional shadow indicators and custom scrollbar styling.
*
* @param props - component properties.
* @param ref - forwarded ref to the scrollable div element.
* @returns the scrollable container element.
*/
function Scrollable(
{
shadow,
topShadow,
bottomShadow,
hiddenScrollbars,
fadeTo,
flex,
overflow,
children,
...rest
}: Props,
ref: React.RefObject<HTMLDivElement>
@@ -36,14 +52,17 @@ function Scrollable(
return;
}
const scrollTop = c.scrollTop;
const tsv = !!((shadow || topShadow) && scrollTop > 0);
const tsv = !!((shadow || topShadow || fadeTo) && scrollTop > 0);
if (tsv !== topShadowVisible) {
setTopShadow(tsv);
}
const wrapperHeight = c.scrollHeight - c.clientHeight;
const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0);
const bsv = !!(
(shadow || bottomShadow || fadeTo) &&
wrapperHeight - scrollTop !== 0
);
if (bsv !== bottomShadowVisible) {
setBottomShadow(bsv);
@@ -52,6 +71,7 @@ function Scrollable(
shadow,
topShadow,
bottomShadow,
fadeTo,
ref,
topShadowVisible,
bottomShadowVisible,
@@ -67,21 +87,59 @@ function Scrollable(
onScroll={updateShadows}
$flex={flex}
$hiddenScrollbars={hiddenScrollbars}
$topShadowVisible={topShadowVisible}
$bottomShadowVisible={bottomShadowVisible}
$topShadowVisible={topShadowVisible && !fadeTo}
$bottomShadowVisible={bottomShadowVisible && !fadeTo}
$overflow={overflow}
{...rest}
/>
>
{fadeTo && <Fade to={fadeTo} visible={topShadowVisible} top />}
{children}
{fadeTo && <Fade to={fadeTo} visible={bottomShadowVisible} bottom />}
</Wrapper>
);
}
const Fade = styled.div<{
to: string;
top?: boolean;
bottom?: boolean;
visible: boolean;
}>`
--height: 1.5em;
position: sticky;
${(props) =>
props.top &&
css`
top: 0;
background: linear-gradient(to bottom, ${props.to}, transparent);
margin-bottom: calc(-1 * var(--height));
`}
${(props) =>
props.bottom &&
css`
bottom: 0;
background: linear-gradient(to top, ${props.to}, transparent);
margin-top: calc(-1 * var(--height));
`}
flex-shrink: 0;
height: var(--height);
width: calc(100% - var(--scrollbar-width, 0px));
pointer-events: none;
opacity: ${(props) => (props.visible ? 1 : 0)};
transition: opacity 100ms ease-in-out;
z-index: 1;
`;
const Wrapper = styled.div<{
$flex?: boolean;
$fadeTo?: string;
$topShadowVisible?: boolean;
$bottomShadowVisible?: boolean;
$hiddenScrollbars?: boolean;
$overflow?: string;
}>`
position: relative;
display: ${(props) => (props.$flex ? "flex" : "block")};
flex-direction: column;
height: 100%;
@@ -331,7 +331,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
) : (
<CopyLinkButton
key="copy-link"
url={urlify(collectionPath(collection.path))}
url={urlify(collectionPath(collection))}
onCopy={onRequestClose}
/>
);
@@ -16,7 +16,7 @@ type Props = {
action: React.ReactNode;
};
export const SearchInput = React.forwardRef(function _SearchInput(
export const SearchInput = React.forwardRef(function SearchInput_(
{ onChange, onClick, onKeyDown, query, back, action }: Props,
ref: React.Ref<HTMLInputElement>
) {
@@ -44,7 +44,7 @@ type Props = {
};
export const Suggestions = observer(
React.forwardRef(function _Suggestions(
React.forwardRef(function Suggestions_(
{
document,
collection,
-2
View File
@@ -30,7 +30,6 @@ import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import ToggleButton from "./components/ToggleButton";
import TrashLink from "./components/TrashLink";
import BulkSelectionToolbar from "../BulkSelectionToolbar";
function AppSidebar() {
const { t } = useTranslation();
@@ -132,7 +131,6 @@ function AppSidebar() {
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
<BulkSelectionToolbar />
</DndProvider>
)}
</Sidebar>
+2 -1
View File
@@ -88,7 +88,8 @@ function SharedSidebar({ share }: Props) {
) : (
<SharedDocumentLink
index={0}
depth={0}
// If the root node has an icon we need some extra space for it
depth={rootNode.icon ? 1 : 0}
shareId={shareId}
node={rootNode}
prefetchDocument={documents.prefetchDocument}
+1 -1
View File
@@ -37,7 +37,7 @@ type Props = {
children: React.ReactNode;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
{ children, hidden = false, canCollapse = true, className }: Props,
ref: React.RefObject<HTMLDivElement>
) {
@@ -78,18 +78,6 @@ function InnerDocumentLink(
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
// Selection state for bulk operations
const isSelected = documents.isSelected(node.id);
const hasAnySelection = documents.selectedDocumentIds.length > 0;
const handleSelectionChange = React.useCallback(() => {
if (isSelected) {
documents.deselect(node.id);
} else {
documents.select(node.id);
}
}, [documents, node.id, isSelected]);
React.useEffect(() => {
if (
isActiveDocument &&
@@ -446,12 +434,6 @@ function InnerDocumentLink(
isDraft={isDraft}
ref={ref}
menu={menuElement}
onSelectionChange={handleSelectionChange}
selectionState={{
isSelected,
hasAnySelection,
showCheckbox: true,
}}
/>
</DropToImport>
</div>
@@ -123,7 +123,7 @@ function SharedWithMe() {
.map((membership) => (
<SharedWithMeLink key={membership.id} membership={membership} />
))}
{!end && (
{!loading && !end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
@@ -18,7 +18,7 @@ export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
};
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
function _SidebarButton(
function SidebarButton_(
{
position = "top",
showMoreMenu,
@@ -14,7 +14,6 @@ import NavLink, { Props as NavLinkProps } from "./NavLink";
import { ActionWithChildren } from "~/types";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useTranslation } from "react-i18next";
import { CheckboxIcon } from "outline-icons";
/**
* Props for the SidebarLink component.
@@ -57,14 +56,6 @@ type Props = Omit<NavLinkProps, "to"> & {
scrollIntoViewIfNeeded?: boolean;
/** Optional context menu action to display */
contextAction?: ActionWithChildren;
/** State of the selection checkbox */
selectionState?: {
isSelected: boolean;
showCheckbox: boolean;
hasAnySelection: boolean;
};
/** Callback fired when the selection checkbox is toggled */
onSelectionChange?: () => void;
};
const activeDropStyle = {
@@ -97,12 +88,6 @@ function SidebarLink(
disabled,
unreadBadge,
contextAction,
selectionState = {
isSelected: false,
showCheckbox: false,
hasAnySelection: false,
},
onSelectionChange,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -111,7 +96,6 @@ function SidebarLink(
const { t } = useTranslation();
const theme = useTheme();
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
const { isSelected, showCheckbox, hasAnySelection } = selectionState;
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + (icon ? -8 : 12)}px`,
@@ -165,7 +149,6 @@ function SidebarLink(
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
$hasCheckbox={showCheckbox}
style={style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
@@ -183,17 +166,6 @@ function SidebarLink(
{...rest}
>
<Content>
{showCheckbox && (
<CheckboxWrapper $alwaysVisible={hasAnySelection}>
<NudeButton
type="button"
onClick={onSelectionChange}
aria-label={t("Select")}
>
<CheckboxIcon checked={isSelected} />
</NudeButton>
</CheckboxWrapper>
)}
{hasDisclosure && (
<DisclosureComponent
expanded={expanded}
@@ -212,23 +184,13 @@ function SidebarLink(
);
}
// accounts for whitespace around icon
export const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
transition: opacity 150ms ease-in-out;
`;
const CheckboxWrapper = styled(EventBoundary)<{ $alwaysVisible?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
margin-left: -11px;
flex-shrink: 0;
opacity: ${(props) => (props.$alwaysVisible ? 1 : 0)};
transition: opacity 150ms ease-in-out;
transition: opacity 200ms ease-in-out;
`;
const Content = styled.span`
@@ -277,7 +239,6 @@ const Link = styled(NavLink)<{
$isActiveDrop?: boolean;
$isDraft?: boolean;
$disabled?: boolean;
$hasCheckbox?: boolean;
}>`
&:hover,
&:active {
@@ -365,14 +326,6 @@ const Link = styled(NavLink)<{
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
${(props) =>
props.$hasCheckbox &&
css`
&:hover ${CheckboxWrapper} {
opacity: 1;
}
`}
}
& ${Actions} {
@@ -63,7 +63,7 @@ function Starred() {
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!end && (
{!loading && !end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
@@ -24,7 +24,12 @@ function TrashLink() {
title: t("Delete {{ documentName }}", {
documentName: document?.noun,
}),
content: <DocumentDelete documents={[document]} />,
content: (
<DocumentDelete
document={document}
onSubmit={dialogs.closeAllModals}
/>
),
});
},
canDrop: (item) => policies.abilities(item.id).delete,
@@ -67,8 +67,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
!props.disabled &&
`
&[data-highlighted],
&:focus-visible,
&:hover {
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
+3 -3
View File
@@ -302,9 +302,8 @@ export default function FindAndReplace({
const style: React.CSSProperties = React.useMemo(
() => ({
position: "fixed",
left: "initial",
top: 60,
right: 16,
top: 0,
right: 0,
zIndex: depths.popover,
}),
[]
@@ -375,6 +374,7 @@ export default function FindAndReplace({
minWidth={420}
scrollable={false}
onPointerDownOutside={() => setLocalOpen(false)}
style={{ marginRight: 16, marginTop: 60 }}
>
<Content column>
<Flex gap={4}>
+4 -2
View File
@@ -294,7 +294,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
<Portal>
<Wrapper
active={props.active && position.visible}
arrow={!position.blockSelection}
arrow={!!props.children && !position.blockSelection}
ref={menuRef}
$offset={position.offset}
style={{
@@ -304,7 +304,9 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
left: `${position.left}px`,
}}
>
<Background align={props.align}>{props.children}</Background>
{props.children && (
<Background align={props.align}>{props.children}</Background>
)}
</Wrapper>
</Portal>
);
+50 -22
View File
@@ -1,7 +1,12 @@
import { observer } from "mobx-react";
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
import {
ArrowIcon,
CloseIcon,
DocumentIcon,
OpenIcon,
ReturnIcon,
} from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
@@ -28,9 +33,25 @@ type Props = {
mark?: Mark;
dictionary: Dictionary;
view: EditorView;
onLinkAdd: () => void;
onLinkUpdate: () => void;
onLinkRemove: () => void;
onEscape: () => void;
onClickOutside: (ev: MouseEvent | TouchEvent) => void;
onClickBack: () => void;
};
const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
const LinkEditor: React.FC<Props> = ({
mark,
dictionary,
view,
onLinkAdd,
onLinkUpdate,
onLinkRemove,
onEscape,
onClickOutside,
onClickBack,
}) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const { commands } = useEditor();
@@ -58,7 +79,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
}
}, [trimmedQuery, request]);
useOnClickOutside(wrapperRef, () => {
useOnClickOutside(wrapperRef, (ev) => {
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return removeLink();
@@ -66,9 +87,14 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
// If the link in input is non-empty and same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
onClickOutside(ev);
return;
}
if (!mark) {
return addLink(trimmedQuery);
}
updateLink(trimmedQuery);
});
@@ -78,26 +104,23 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
const removeLink = React.useCallback(() => {
commands["removeLink"]();
}, []);
onLinkRemove();
}, [commands, onLinkRemove]);
const updateLink = (link: string) => {
if (!link) {
return;
}
commands["updateLink"]({ href: sanitizeUrl(link) ?? "" });
onLinkUpdate();
};
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(
state.tr.doc.resolve(state.selection.to),
1,
true
);
if (nextSelection) {
dispatch(state.tr.setSelection(nextSelection));
const addLink = (link: string) => {
if (!link) {
return;
}
view.focus();
commands["addLink"]({ href: sanitizeUrl(link) ?? "" });
onLinkAdd();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
@@ -119,9 +142,11 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
if (selectedIndex >= 0 && results[selectedIndex]) {
const selectedDoc = results[selectedIndex];
updateLink(selectedDoc.url);
!mark ? addLink(selectedDoc.url) : updateLink(selectedDoc.url);
} else if (!trimmedQuery) {
removeLink();
} else if (!mark) {
addLink(trimmedQuery);
} else {
updateLink(trimmedQuery);
}
@@ -135,11 +160,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
return removeLink();
}
// Moving selection to end causes editor state to change,
// forcing a re-render of the top-level editor component. As
// a result, the new selection, being devoid of any link mark,
// prevents LinkEditor from re-rendering.
moveSelectionToEnd();
onEscape();
return;
}
}
@@ -169,6 +190,13 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
disabled: false,
handler: removeLink,
},
{
tooltip: dictionary.formattingControls,
icon: <ReturnIcon />,
visible: view.editable,
disabled: false,
handler: onClickBack,
},
];
return (
@@ -208,7 +236,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
{results.map((doc, index) => (
<SuggestionsMenuItem
onPointerDown={() => {
updateLink(doc.url);
!mark ? addLink(doc.url) : updateLink(doc.url);
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
+36 -8
View File
@@ -2,24 +2,43 @@ import { OpenIcon, TrashIcon } from "outline-icons";
import { Node } from "prosemirror-model";
import { Selection, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Tooltip from "~/components/Tooltip";
import Input from "~/editor/components/Input";
import { Dictionary } from "~/hooks/useDictionary";
import ToolbarButton from "./ToolbarButton";
import useOnClickOutside from "~/hooks/useOnClickOutside";
type Props = {
node: Node;
node?: Node;
view: EditorView;
dictionary: Dictionary;
autoFocus?: boolean;
onLinkUpdate: () => void;
onLinkRemove: () => void;
onEscape: () => void;
onClickOutside: (ev: MouseEvent | TouchEvent) => void;
};
export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const url = (node.attrs.href ?? node.attrs.src) as string;
export function MediaLinkEditor({
node,
view,
dictionary,
onLinkUpdate,
onLinkRemove,
onEscape,
onClickOutside,
}: Props) {
const url = (node?.attrs.href ?? node?.attrs.src) as string;
const [localUrl, setLocalUrl] = useState(url);
const wrapperRef = useRef<HTMLDivElement>(null);
// If we're attempting to edit an image, autofocus the input
// Not doing for embed type because it made the editor scroll to top
// unexpectedlyleaving that out for now
const isEditingImgUrl = node?.type.name === "image";
const moveSelectionToEnd = useCallback(() => {
const { state, dispatch } = view;
@@ -41,20 +60,24 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const remove = useCallback(() => {
const { state, dispatch } = view;
dispatch(state.tr.deleteSelection());
onLinkRemove();
}, [view]);
const update = useCallback(() => {
const { state } = view;
const hrefType = node.type.name === "image" ? "src" : "href";
const hrefType = node?.type.name === "image" ? "src" : "href";
const tr = state.tr.setNodeMarkup(state.selection.from, undefined, {
...node.attrs,
...node?.attrs,
[hrefType]: localUrl,
});
view.dispatch(tr);
moveSelectionToEnd();
onLinkUpdate();
}, [localUrl, node, view, moveSelectionToEnd]);
useOnClickOutside(wrapperRef, onClickOutside);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) {
@@ -71,6 +94,7 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
case "Escape": {
event.preventDefault();
moveSelectionToEnd();
onEscape();
return;
}
}
@@ -78,10 +102,14 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
[update, moveSelectionToEnd]
);
if (!node) {
return null;
}
return (
<Wrapper>
<Wrapper ref={wrapperRef}>
<Input
autoFocus={autoFocus}
autoFocus={isEditingImgUrl}
value={localUrl}
placeholder={dictionary.pasteLink}
onChange={(e) => setLocalUrl(e.target.value)}
+117 -34
View File
@@ -1,7 +1,10 @@
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import {
getMarkRange,
getMarkRangeNodeSelection,
} from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
@@ -30,6 +33,7 @@ import { MediaLinkEditor } from "./MediaLinkEditor";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
import { isModKey } from "@shared/utils/keyboard";
type Props = {
/** Whether the text direction is right-to-left */
@@ -56,6 +60,12 @@ function useIsDragging() {
return isDragging;
}
enum Toolbar {
Link = "link",
Media = "media",
Menu = "menu",
}
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, commands } = useEditor();
@@ -64,11 +74,41 @@ export function SelectionToolbar(props: Props) {
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
const isDragging = useIsDragging();
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
const { state } = view;
const { selection } = state;
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
null
);
React.useEffect(() => {
setIsEditingImgUrl(false);
}, [isActive]);
const { selection } = state;
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
const isEmbedSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
if (isEmbedSelection && !readOnly) {
setActiveToolbar(Toolbar.Media);
} else if (linkMark && !activeToolbar && !readOnly) {
setActiveToolbar(Toolbar.Link);
} else if (isCodeSelection) {
setActiveToolbar(Toolbar.Menu);
} else if (!selection.empty) {
setActiveToolbar(Toolbar.Menu);
} else if (isNoticeSelection && selection.empty) {
setActiveToolbar(Toolbar.Menu);
} else if (selection.empty) {
setActiveToolbar(null);
}
}, [readOnly, selection]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
@@ -91,8 +131,6 @@ export function SelectionToolbar(props: Props) {
return;
}
setIsEditingImgUrl(false);
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
@@ -106,27 +144,46 @@ export function SelectionToolbar(props: Props) {
};
}, [isActive, readOnly, view]);
useEventListener(
"keydown",
(ev: KeyboardEvent) => {
if (
isModKey(ev) &&
ev.key.toLowerCase() === "k" &&
!view.state.selection.empty
) {
ev.stopPropagation();
if (activeToolbar === Toolbar.Link) {
setActiveToolbar(Toolbar.Menu);
} else if (activeToolbar === Toolbar.Menu) {
setActiveToolbar(Toolbar.Link);
}
}
},
view.dom,
{ capture: true }
);
if (isDragging) {
return null;
}
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const link = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
const isAttachmentSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isEmbedSelection =
selection instanceof NodeSelection && selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
const link =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
@@ -178,47 +235,73 @@ export function SelectionToolbar(props: Props) {
});
items = filterExcessSeparators(items);
if (!items.length) {
return null;
}
items = items.map((item) => {
if (item.children) {
item.children = item.children.map((child) => {
if (child.name === "editImageUrl") {
child.onClick = () => {
setActiveToolbar(Toolbar.Media);
};
}
return child;
});
}
const showLinkToolbar =
link && link.from === selection.from && link.to === selection.to;
if (item.name === "linkOnImage" || item.name === "addLink") {
item.onClick = () => {
setActiveToolbar(Toolbar.Link);
};
}
return item;
});
const isEditingMedia =
isEmbedSelection || (isImageSelection && isEditingImgUrl);
const handleClickOutsideLinkEditor = (ev: MouseEvent | TouchEvent) => {
if (ev.target instanceof Element && ev.target.closest(".image-wrapper")) {
return;
}
setActiveToolbar(null);
};
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
width={
activeToolbar === Toolbar.Link || activeToolbar === Toolbar.Media
? 336
: undefined
}
>
{showLinkToolbar ? (
{activeToolbar === Toolbar.Link ? (
<LinkEditor
key={`${link.from}-${link.to}`}
key={`${selection.from}-${selection.to}`}
dictionary={dictionary}
view={view}
mark={link.mark}
mark={link ? link.mark : undefined}
onLinkAdd={() => setActiveToolbar(null)}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
onEscape={() => setActiveToolbar(Toolbar.Menu)}
onClickOutside={handleClickOutsideLinkEditor}
onClickBack={() => setActiveToolbar(Toolbar.Menu)}
/>
) : isEditingMedia ? (
) : activeToolbar === Toolbar.Media ? (
<MediaLinkEditor
key={`embed-${selection.from}`}
node={selection.node}
node={
"node" in selection ? (selection as NodeSelection).node : undefined
}
view={view}
dictionary={dictionary}
autoFocus={isEditingImgUrl}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
onEscape={() => setActiveToolbar(Toolbar.Menu)}
onClickOutside={handleClickOutsideLinkEditor}
/>
) : (
<ToolbarMenu
items={items}
{...rest}
handlers={{
editImageUrl: () => setIsEditingImgUrl(true),
}}
/>
)}
) : activeToolbar === Toolbar.Menu && items.length ? (
<ToolbarMenu items={items} {...rest} />
) : null}
</FloatingToolbar>
);
}
+11 -14
View File
@@ -20,20 +20,15 @@ import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
handlers?: Record<string, (...args: any[]) => void>;
};
/*
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: {
active: boolean;
item: MenuItem;
handlers?: Record<string, Function>;
}) {
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item, handlers } = props;
const { item } = props;
const { state } = view;
const items: TMenuItem[] = useMemo(() => {
@@ -48,12 +43,8 @@ function ToolbarDropdown(props: {
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (handlers && handlers[menuItem.name]) {
handlers[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (menuItem.onClick) {
menuItem.onClick();
}
};
@@ -113,6 +104,13 @@ function ToolbarMenu(props: Props) {
return;
}
// if item has an associated onClick prop, run it
if (item.onClick) {
item.onClick();
return;
}
// otherwise, run the associated editor command
commands[item.name](
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
);
@@ -141,7 +139,6 @@ function ToolbarMenu(props: Props) {
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
handlers={props.handlers}
active={isActive && !item.label}
item={item}
/>
+11 -17
View File
@@ -282,28 +282,22 @@ export default class PasteHandler extends Extension {
const slice = paste.slice(0);
const tr = view.state.tr;
let currentPos = view.state.selection.from;
// If the pasted content is a single paragraph then we loop over
// it's content and insert each node one at a time to allow it to
// be pasted inline with surrounding content.
// If the pasted content is a single paragraph then we slice
// the outer paragraph so that the text is inserted directly.
const singleNode = sliceSingleNode(slice);
if (singleNode?.type === this.editor.schema.nodes.paragraph) {
singleNode.forEach((node) => {
tr.insert(currentPos, node);
currentPos += node.nodeSize;
});
} else {
if (singleNode) {
if (isList(singleNode, this.editor.schema)) {
this.handleList(singleNode);
return true;
} else {
tr.replaceSelectionWith(singleNode, this.shiftKey);
}
const slice = new Slice(singleNode.content, 0, 0);
tr.replaceSelection(slice);
} else if (singleNode) {
if (isList(singleNode, this.editor.schema)) {
this.handleList(singleNode);
return true;
} else {
tr.replaceSelection(slice);
tr.replaceSelectionWith(singleNode, this.shiftKey);
}
} else {
tr.replaceSelection(slice);
}
view.dispatch(
+1 -1
View File
@@ -914,7 +914,7 @@ const EditorContainer = styled(Styles)<{
`;
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
function _LazyLoadedEditor(props: Props, ref) {
function LazyLoadedEditor_(props: Props, ref) {
return (
<WithTheme>
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
+1
View File
@@ -258,6 +258,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+K`,
icon: <LinkIcon />,
attrs: { href: "" },
active: isMarkActive(schema.marks.link, undefined, { exact: true }),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
+9
View File
@@ -8,6 +8,7 @@ import {
AlignFullWidthIcon,
EditIcon,
CommentIcon,
LinkIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
@@ -16,6 +17,7 @@ import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
import { ImageSource } from "@shared/editor/lib/FileHelper";
import Desktop from "~/utils/Desktop";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
export default function imageMenuItems(
state: EditorState,
@@ -123,6 +125,13 @@ export default function imageMenuItems(
{
name: "separator",
},
{
name: "linkOnImage",
tooltip: dictionary.createLink,
shortcut: `${metaDisplay}+K`,
active: isMarkActive(schema.marks.link),
icon: <LinkIcon />,
},
{
name: "commentOnImage",
tooltip: dictionary.comment,
+12 -1
View File
@@ -1,4 +1,9 @@
import { AlignFullWidthIcon, DownloadIcon, TrashIcon } from "outline-icons";
import {
AlignFullWidthIcon,
DownloadIcon,
TableColumnsDistributeIcon,
TrashIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem, TableLayout } from "@shared/editor/types";
@@ -13,6 +18,7 @@ export default function tableMenuItems(
return [];
}
const { schema } = state;
const isFullWidth = isNodeActive(schema.nodes.table, {
layout: TableLayout.fullWidth,
})(state);
@@ -27,6 +33,11 @@ export default function tableMenuItems(
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
active: () => isFullWidth,
},
{
name: "distributeColumns",
tooltip: dictionary.distributeColumns,
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
+9
View File
@@ -11,11 +11,13 @@ import {
TableSplitCellsIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
TableColumnsDistributeIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getAllSelectedColumns,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
@@ -38,6 +40,7 @@ export default function tableColMenuItems(
const { index, rtl } = options;
const { schema, selection } = state;
const selectedCols = getAllSelectedColumns(state);
if (!(selection instanceof CellSelection)) {
return [];
@@ -147,6 +150,12 @@ export default function tableColMenuItems(
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "distributeColumns",
visible: selectedCols.length > 1,
label: dictionary.distributeColumns,
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
-35
View File
@@ -1,35 +0,0 @@
import { useMemo } from "react";
import {
bulkArchiveDocuments,
bulkDeleteDocuments,
bulkMoveDocuments,
} from "~/actions/definitions/bulkDocuments";
import Document from "~/models/Document";
import { useMenuAction } from "./useMenuAction";
type Props = {
/** Documents that are selected */
documents: Document[];
};
/**
* Hook that creates bulk document menu actions.
*
* @param props - documents and callbacks.
* @returns root menu action with children for bulk operations.
*/
export function useBulkDocumentMenuAction({ documents }: Props) {
const actions = useMemo(() => {
if (!documents.length) {
return [];
}
return [
bulkArchiveDocuments({ documents }),
bulkMoveDocuments({ documents }),
bulkDeleteDocuments({ documents }),
];
}, [documents]);
return useMenuAction(actions);
}
+2
View File
@@ -110,6 +110,8 @@ export default function useDictionary() {
none: t("None"),
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
formattingControls: t("Formatting controls"),
distributeColumns: t("Distribute columns"),
}),
[t]
);
+1 -1
View File
@@ -37,7 +37,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
if (href[0] !== "/") {
try {
const url = new URL(href);
navigateTo = url.pathname + url.hash;
navigateTo = url.pathname + url.search + url.hash;
} catch (_err) {
navigateTo = href;
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { observable } from "mobx";
import { computed, observable } from "mobx";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
@@ -36,6 +36,11 @@ class Emoji extends Model {
return this.name;
}
@computed
get shortName(): string {
return `:${this.name}:`;
}
/**
* emoji name
*/
+1 -1
View File
@@ -177,7 +177,7 @@ class Notification extends Model {
const collection = this.collectionId
? this.store.rootStore.collections.get(this.collectionId)
: undefined;
return collection ? collectionPath(collection.path) : "";
return collection ? collectionPath(collection) : "";
}
case NotificationEventType.AddUserToDocument:
case NotificationEventType.GroupMentionedInDocument:
+197
View File
@@ -0,0 +1,197 @@
import User from "./User";
import stores from "~/stores";
describe("User model", () => {
const users = stores.users;
describe("initial", () => {
test("should return first character of name uppercased", () => {
const user = new User(
{
id: "123",
name: "alice smith",
},
users
);
expect(user.initial).toBe("A");
});
test("should return first character when name is already uppercase", () => {
const user = new User(
{
id: "124",
name: "Bob Johnson",
},
users
);
expect(user.initial).toBe("B");
});
test("should return ? when name is empty", () => {
const user = new User(
{
id: "125",
name: "",
},
users
);
expect(user.initial).toBe("?");
});
test("should return ? when name is null", () => {
const user = new User(
{
id: "126",
name: null,
},
users
);
expect(user.initial).toBe("?");
});
test("should return ? when name is undefined", () => {
const user = new User(
{
id: "127",
name: undefined,
},
users
);
expect(user.initial).toBe("?");
});
});
describe("initials", () => {
test("should return empty string when name is empty", () => {
const user = new User(
{
id: "201",
name: "",
},
users
);
expect(user.initials).toBe("");
});
test("should return empty string when name is null", () => {
const user = new User(
{
id: "202",
name: null,
},
users
);
expect(user.initials).toBe("");
});
test("should return single character uppercased for single word name", () => {
const user = new User(
{
id: "203",
name: "alice",
},
users
);
expect(user.initials).toBe("A");
});
test("should return single character uppercased for single word name already uppercase", () => {
const user = new User(
{
id: "204",
name: "BOB",
},
users
);
expect(user.initials).toBe("B");
});
test("should return first and last initials for two word name", () => {
const user = new User(
{
id: "205",
name: "alice smith",
},
users
);
expect(user.initials).toBe("AS");
});
test("should return first and last initials for three word name", () => {
const user = new User(
{
id: "206",
name: "alice marie smith",
},
users
);
expect(user.initials).toBe("AS");
});
test("should return first and last initials for many word name", () => {
const user = new User(
{
id: "207",
name: "alice marie jane doe smith",
},
users
);
expect(user.initials).toBe("AS");
});
test("should handle names with extra spaces", () => {
const user = new User(
{
id: "208",
name: " alice smith ",
},
users
);
expect(user.initials).toBe("AS");
});
test("should handle names with mixed case", () => {
const user = new User(
{
id: "209",
name: "aLiCe sMiTh",
},
users
);
expect(user.initials).toBe("AS");
});
test("should handle names with special characters", () => {
const user = new User(
{
id: "210",
name: "Jean-Pierre O'Connor",
},
users
);
expect(user.initials).toBe("JO");
});
test("should handle single letter names", () => {
const user = new User(
{
id: "211",
name: "X",
},
users
);
expect(user.initials).toBe("X");
});
test("should handle names with unicode characters", () => {
const user = new User(
{
id: "212",
name: "José García",
},
users
);
expect(user.initials).toBe("JG");
});
});
});
+13 -1
View File
@@ -65,7 +65,7 @@ class User extends ParanoidModel implements Searchable {
@computed
get searchContent(): string[] {
return [this.name, this.email].filter(Boolean);
return [this.name, this.email, this.initials].filter(Boolean);
}
@computed
@@ -78,6 +78,18 @@ class User extends ParanoidModel implements Searchable {
return (this.name ? this.name[0] : "?").toUpperCase();
}
@computed
get initials(): string {
if (!this.name) {
return "";
}
const names = this.name.trim().split(" ");
if (names.length === 1) {
return names[0][0].toUpperCase();
}
return (names[0][0] + names[names.length - 1][0]).toUpperCase();
}
/**
* Whether the user has been invited but not yet signed in.
*/
+25 -7
View File
@@ -17,7 +17,8 @@ import {
homePath,
searchPath,
settingsPath,
matchDocumentSlug as slug,
matchDocumentSlug as documentSlug,
matchCollectionSlug as collectionSlug,
trashPath,
} from "~/utils/routeHelpers";
@@ -80,22 +81,39 @@ function AuthenticatedRoutes() {
to={settingsPath("templates")}
/>
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route
exact
path="/collection/:id/:tab?"
path={`/collection/${collectionSlug}/new`}
component={DocumentNew}
/>
<Route
exact
path={`/collection/${collectionSlug}/overview/edit`}
component={Collection}
/>
<Route
exact
path={`/collection/${collectionSlug}/:tab?`}
component={Collection}
/>
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
path={`/d/${documentSlug}`}
component={RedirectDocument}
/>
<Route
exact
path={`/doc/${documentSlug}/history/:revisionId?`}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route
exact
path={`/doc/${documentSlug}/edit`}
component={Document}
/>
<Route path={`/doc/${documentSlug}`} component={Document} />
<Route
exact
path={`${searchPath()}/:query?`}
+62 -3
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import { EditIcon, PlusIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import Collection from "~/models/Collection";
@@ -8,18 +8,76 @@ import Button from "~/components/Button";
import Tooltip from "~/components/Tooltip";
import usePolicy from "~/hooks/usePolicy";
import CollectionMenu from "~/menus/CollectionMenu";
import { newDocumentPath } from "~/utils/routeHelpers";
import {
collectionEditPath,
collectionPath,
newDocumentPath,
} from "~/utils/routeHelpers";
import useCurrentUser from "~/hooks/useCurrentUser";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import { CollectionTab } from "./Navigation";
import lazyWithRetry from "~/utils/lazyWithRetry";
import history from "~/utils/history";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import { useCallback } from "react";
const ShareButton = lazyWithRetry(() => import("./ShareButton"));
type Props = {
/** The collection for which to render actions */
collection: Collection;
/** Whether the collection is in editing mode */
isEditing: boolean;
/** Contextual information for the sidebar */
sidebarContext: SidebarContextType;
};
function Actions({ collection }: Props) {
function Actions({ collection, isEditing, sidebarContext }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection);
const user = useCurrentUser();
const goToEdit = useCallback(() => {
history.push({
pathname: collectionEditPath(collection),
state: { sidebarContext },
});
}, [collection, sidebarContext]);
const goBack = useCallback(() => {
history.push({
pathname: collectionPath(collection, CollectionTab.Overview),
state: { sidebarContext },
});
}, [collection, sidebarContext]);
return (
<>
{(!isEditing || !user?.separateEditMode) && (
<Action>
<ShareButton collection={collection} />
</Action>
)}
{!isEditing && user?.separateEditMode && (
<Action>
<RegisterKeyDown trigger="e" handler={goToEdit} />
<Tooltip
content={t("Edit collection")}
shortcut="e"
placement="bottom"
>
<Button icon={<EditIcon />} onClick={goToEdit} neutral>
{t("Edit")}
</Button>
</Tooltip>
</Action>
)}
{isEditing && user?.separateEditMode && (
<Action>
<RegisterKeyDown trigger="Escape" handler={goBack} />
<Button onClick={goBack}>{t("Done editing")}</Button>
</Action>
)}
{can.createDocument && (
<>
<Action>
@@ -33,6 +91,7 @@ function Actions({ collection }: Props) {
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
neutral={isEditing}
>
{t("New doc")}
</Button>
@@ -0,0 +1,68 @@
import { IconTitleWrapper } from "@shared/components/Icon";
import breakpoint from "styled-components-breakpoint";
import first from "lodash/first";
import { Suspense, useCallback } from "react";
import styled from "styled-components";
import Heading from "~/components/Heading";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type Collection from "~/models/Collection";
import { colorPalette } from "@shared/utils/collections";
import usePolicy from "~/hooks/usePolicy";
import { observer } from "mobx-react";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
type Props = {
/** The collection for which to render a header */
collection: Collection;
};
export const Header = observer(function Header_({ collection }: Props) {
const can = usePolicy(collection);
const handleIconChange = useCallback(
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
[collection]
);
const fallbackIcon = collection ? (
<CollectionIcon collection={collection} size={40} expanded />
) : null;
return (
<StyledHeading>
<IconTitleWrapper>
{can.update ? (
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? (first(colorPalette) as string)}
initial={collection.initial}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
>
{fallbackIcon}
</IconPicker>
</Suspense>
) : (
fallbackIcon
)}
</IconTitleWrapper>
{collection.name}
</StyledHeading>
);
});
const StyledHeading = styled(Heading)`
display: flex;
align-items: center;
position: relative;
margin-left: 40px;
${breakpoint("tablet")`
margin-left: 0;
`}
`;
@@ -0,0 +1,78 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { collectionPath } from "~/utils/routeHelpers";
import { type SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
export enum CollectionTab {
Overview = "overview",
Recent = "recent",
Popular = "popular",
Updated = "updated",
Published = "published",
Old = "old",
Alphabetical = "alphabetical",
}
type Props = {
/** The collection for which to render navigation tabs */
collection: Collection;
/** Callback when the tab is changed */
onChangeTab: (tab: CollectionTab) => void;
/** Whether to show the overview tab */
showOverview?: boolean;
/** Contextual information for the sidebar */
sidebarContext: SidebarContextType;
};
/**
* Navigation component for collection tabs, providing navigation between
* different views of collection documents.
*/
const Navigation = observer(function Navigation({
collection,
onChangeTab,
showOverview,
sidebarContext,
}: Props) {
const { t } = useTranslation();
const tabProps = (path: CollectionTab) => ({
exact: true,
onClick: () => onChangeTab(path),
to: {
pathname: collectionPath(collection, path),
state: { sidebarContext },
},
});
return (
<Tabs>
{showOverview && (
<Tab {...tabProps(CollectionTab.Overview)} exact={false}>
{t("Overview")}
</Tab>
)}
<Tab {...tabProps(CollectionTab.Recent)}>{t("Documents")}</Tab>
{!collection.isArchived && (
<>
<Tab {...tabProps(CollectionTab.Popular)}>{t("Popular")}</Tab>
<Tab {...tabProps(CollectionTab.Updated)}>
{t("Recently updated")}
</Tab>
<Tab {...tabProps(CollectionTab.Published)}>
{t("Recently published")}
</Tab>
<Tab {...tabProps(CollectionTab.Old)}>
{t("Least recently updated")}
</Tab>
<Tab {...tabProps(CollectionTab.Alphabetical)}>{t("AZ")}</Tab>
</>
)}
</Tabs>
);
});
export default Navigation;
+11 -4
View File
@@ -1,6 +1,6 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { useMemo, useRef, useCallback, Suspense } from "react";
import { useMemo, useRef, useCallback, useEffect, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
@@ -24,10 +24,10 @@ const extensions = withUIExtensions(richExtensions);
type Props = {
collection: Collection;
shareId?: string;
readOnly?: boolean;
};
function Overview({ collection, shareId }: Props) {
function Overview({ collection, readOnly }: Props) {
const { documents, collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
@@ -48,6 +48,13 @@ function Overview({ collection, shareId }: Props) {
[collection, t]
);
useEffect(
() => () => {
handleSave.flush();
},
[handleSave]
);
const childRef = useRef<HTMLDivElement>(null);
const childOffsetHeight = childRef.current?.offsetHeight || 0;
const editorStyle = useMemo(
@@ -91,7 +98,7 @@ function Overview({ collection, shareId }: Props) {
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update || !!shareId}
readOnly={!can.update || readOnly}
userId={user?.id}
editorStyle={editorStyle}
/>
+69 -156
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useState, useCallback, useEffect, Suspense } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
useParams,
@@ -11,13 +11,9 @@ import {
Redirect,
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { IconTitleWrapper } from "@shared/components/Icon";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import Heading from "~/components/Heading";
@@ -28,77 +24,62 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePersistedState from "~/hooks/usePersistedState";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { NotFoundError } from "~/utils/errors";
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
import {
collectionEditPath,
collectionPath,
matchCollectionEdit,
updateCollectionPath,
} from "~/utils/routeHelpers";
import Error404 from "../Errors/Error404";
import Actions from "./components/Actions";
import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Navigation, { CollectionTab } from "./components/Navigation";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import first from "lodash/first";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { Header } from "./components/Header";
import usePersistedState from "~/hooks/usePersistedState";
import useCurrentUser from "~/hooks/useCurrentUser";
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
const ShareButton = lazyWithRetry(() => import("./components/ShareButton"));
enum CollectionPath {
Overview = "overview",
Recent = "recent",
Popular = "popular",
Updated = "updated",
Published = "published",
Old = "old",
Alphabetical = "alphabetical",
}
const CollectionScene = observer(function _CollectionScene() {
const params = useParams<{ id?: string }>();
const CollectionScene = observer(function CollectionScene_() {
const params = useParams<{ collectionSlug?: string }>();
const history = useHistory();
const match = useRouteMatch();
const location = useLocation();
const { t } = useTranslation();
const user = useCurrentUser();
const { documents, collections, shares, ui } = useStores();
const [error, setError] = useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
const sidebarContext = useLocationSidebarContext();
const isEditRoute = match.path === matchCollectionEdit;
const id = params.id || "";
const id = params.collectionSlug || "";
const urlId = id.split("-").pop() ?? "";
const collection: Collection | null | undefined = collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
const [collectionTab, setCollectionTab] = usePersistedState<CollectionTab>(
`collection-tab:${collection?.id}`,
collection?.hasDescription
? CollectionPath.Overview
: CollectionPath.Recent,
collection?.hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
{
listen: false,
}
);
const handleIconChange = useCallback(
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
[collection]
);
useEffect(() => {
setLastVisitedPath(currentPath);
}, [currentPath, setLastVisitedPath]);
@@ -149,23 +130,13 @@ const CollectionScene = observer(function _CollectionScene() {
if (!collection && error) {
return <Error404 />;
}
if (!collection) {
return <Loading />;
}
const hasOverview = can.update || collection?.hasDescription;
const showOverview = can.update || collection?.hasDescription;
const fallbackIcon = collection ? (
<CollectionIcon collection={collection} size={40} expanded />
) : null;
const tabProps = (path: CollectionPath) => ({
exact: true,
onClick: () => setCollectionTab(path),
to: {
pathname: collectionPath(collection!.path, path),
state: { sidebarContext },
},
});
return collection ? (
return (
<Scene
centered={false}
textTitle={collection.name}
@@ -190,10 +161,11 @@ const CollectionScene = observer(function _CollectionScene() {
actions={
<>
<MembershipPreview collection={collection} />
<Action>
{can.update && <ShareButton collection={collection} />}
</Action>
<Actions collection={collection} />
<Actions
collection={collection}
isEditing={isEditRoute}
sidebarContext={sidebarContext}
/>
</>
}
>
@@ -204,28 +176,7 @@ const CollectionScene = observer(function _CollectionScene() {
>
<CenteredContent withStickyHeader>
<Notices collection={collection} />
<CollectionHeading>
<IconTitleWrapper>
{can.update ? (
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? (first(colorPalette) as string)}
initial={collection.initial}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
>
{fallbackIcon}
</IconPicker>
</Suspense>
) : (
fallbackIcon
)}
</IconTitleWrapper>
{collection.name}
</CollectionHeading>
<Header collection={collection} />
<PinnedDocuments
pins={pins}
@@ -233,54 +184,39 @@ const CollectionScene = observer(function _CollectionScene() {
placeholderCount={count}
/>
<Documents>
<Tabs>
{hasOverview && (
<Tab {...tabProps(CollectionPath.Overview)}>
{t("Overview")}
</Tab>
)}
<Tab {...tabProps(CollectionPath.Recent)}>{t("Documents")}</Tab>
{!collection.isArchived && (
<>
<Tab {...tabProps(CollectionPath.Popular)}>
{t("Popular")}
</Tab>
<Tab {...tabProps(CollectionPath.Updated)}>
{t("Recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Published)}>
{t("Recently published")}
</Tab>
<Tab {...tabProps(CollectionPath.Old)}>
{t("Least recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Alphabetical)}>
{t("AZ")}
</Tab>
</>
)}
</Tabs>
<Content>
<Navigation
collection={collection}
onChangeTab={setCollectionTab}
showOverview={showOverview}
sidebarContext={sidebarContext}
/>
<Switch>
<Route path={collectionPath(collection.path)} exact>
<Route path={collectionPath(collection)} exact>
<Redirect
to={{
pathname: collectionPath(collection!.path, collectionTab),
pathname: collectionPath(collection!, collectionTab),
state: { sidebarContext },
}}
/>
</Route>
<Route
path={collectionPath(collection.path, CollectionPath.Overview)}
path={[
collectionPath(collection, CollectionTab.Overview),
collectionEditPath(collection),
]}
>
{hasOverview ? (
<Overview collection={collection} />
{showOverview ? (
<Overview
collection={collection}
readOnly={!isEditRoute && !!user?.separateEditMode}
/>
) : (
<Redirect
to={{
pathname: collectionPath(
collection.path,
CollectionPath.Recent
collection,
CollectionTab.Recent
),
state: { sidebarContext },
}}
@@ -293,8 +229,8 @@ const CollectionScene = observer(function _CollectionScene() {
<>
<Route
path={collectionPath(
collection.path,
CollectionPath.Alphabetical
collection,
CollectionTab.Alphabetical
)}
>
<PaginatedDocumentList
@@ -308,9 +244,7 @@ const CollectionScene = observer(function _CollectionScene() {
}}
/>
</Route>
<Route
path={collectionPath(collection.path, CollectionPath.Old)}
>
<Route path={collectionPath(collection, CollectionTab.Old)}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
@@ -323,10 +257,7 @@ const CollectionScene = observer(function _CollectionScene() {
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Published
)}
path={collectionPath(collection, CollectionTab.Published)}
>
<PaginatedDocumentList
key="published"
@@ -341,10 +272,7 @@ const CollectionScene = observer(function _CollectionScene() {
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Updated
)}
path={collectionPath(collection, CollectionTab.Updated)}
>
<PaginatedDocumentList
key="updated"
@@ -358,10 +286,7 @@ const CollectionScene = observer(function _CollectionScene() {
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Popular
)}
path={collectionPath(collection, CollectionTab.Popular)}
>
<PaginatedDocumentList
key="popular"
@@ -373,10 +298,7 @@ const CollectionScene = observer(function _CollectionScene() {
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Recent
)}
path={collectionPath(collection, CollectionTab.Recent)}
exact
>
<PaginatedDocumentList
@@ -394,7 +316,7 @@ const CollectionScene = observer(function _CollectionScene() {
</>
) : (
<Route
path={collectionPath(collection.path, CollectionPath.Recent)}
path={collectionPath(collection, CollectionTab.Recent)}
exact
>
<PaginatedDocumentList
@@ -412,20 +334,22 @@ const CollectionScene = observer(function _CollectionScene() {
</Route>
)}
</Switch>
</Documents>
</Content>
</CenteredContent>
</DropToImport>
</Scene>
) : (
<CenteredContent>
<Heading>
<PlaceholderText height={35} />
</Heading>
<PlaceholderList count={5} />
</CenteredContent>
);
});
const Loading = () => (
<CenteredContent>
<Heading>
<PlaceholderText height={35} />
</Heading>
<PlaceholderList count={5} />
</CenteredContent>
);
const KeyedCollection = () => {
const params = useParams<{ id?: string }>();
@@ -434,20 +358,9 @@ const KeyedCollection = () => {
return <CollectionScene key={params.id} />;
};
const Documents = styled.div`
const Content = styled.div`
position: relative;
background: ${s("background")};
`;
const CollectionHeading = styled(Heading)`
display: flex;
align-items: center;
position: relative;
margin-left: 40px;
${breakpoint("tablet")`
margin-left: 0;
`}
`;
export default KeyedCollection;
+11 -9
View File
@@ -4,7 +4,7 @@ import isEqual from "lodash/isEqual";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import { Node } from "prosemirror-model";
import { AllSelection, TextSelection } from "prosemirror-state";
import { AllSelection, Selection, TextSelection } from "prosemirror-state";
import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import {
@@ -148,10 +148,7 @@ class DocumentScene extends React.Component<Props> {
* @param template The template to use
* @param selection The selection to replace, if any
*/
replaceSelection = (
template: Document | Revision,
selection?: TextSelection | AllSelection
) => {
replaceSelection = (template: Document | Revision, selection?: Selection) => {
const editorRef = this.editor.current;
if (!editorRef) {
@@ -250,7 +247,7 @@ class DocumentScene extends React.Component<Props> {
if (abilities.move) {
dialogs.openModal({
title: t("Move document"),
content: <DocumentMove documents={[document]} />,
content: <DocumentMove document={document} />,
});
}
};
@@ -418,14 +415,19 @@ class DocumentScene extends React.Component<Props> {
});
handleSelectTemplate = async (template: Document | Revision) => {
const doc = this.editor.current?.view.state.doc;
if (!doc) {
const editorRef = this.editor.current;
if (!editorRef) {
return;
}
const { view } = editorRef;
const doc = view.state.doc;
return this.replaceSelection(
template,
ProsemirrorHelper.isEmpty(doc) ? new AllSelection(doc) : undefined
ProsemirrorHelper.isEmpty(doc)
? new AllSelection(doc)
: view.state.selection
);
};
@@ -56,7 +56,7 @@ type Props = {
const lineHeight = "1.25";
const fontSize = "2.25em";
const DocumentTitle = React.forwardRef(function _DocumentTitle(
const DocumentTitle = React.forwardRef(function DocumentTitle_(
{
documentId,
title,
-98
View File
@@ -1,98 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Document from "~/models/Document";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
documents: Document[];
onSubmit?: () => void;
};
function DocumentArchive({ documents, onSubmit }: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const [isArchiving, setArchiving] = React.useState(false);
const isBulkAction = documents.length > 1;
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setArchiving(true);
try {
const results = await Promise.allSettled(
documents.map((document) => document.archive())
);
const errorCount = results.filter(
(r) => r.status === "rejected"
).length;
if (errorCount === documents.length) {
throw new Error(
t("Couldn't archive the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
}
if (isBulkAction) {
const successCount = results.filter(
(r) => r.status === "fulfilled"
).length;
if (errorCount === 0) {
toast.success(
t("{{ count }} documents archived", { count: successCount })
);
} else {
toast.warning(
t("{{ errorCount }} documents failed to archive, try again?", {
errorCount,
})
);
}
} else {
toast.success(t("Document archived"));
}
onSubmit?.();
dialogs.closeAllModals();
} catch (err) {
toast.error(err.message);
} finally {
setArchiving(false);
}
},
[onSubmit, documents, t, isBulkAction, dialogs]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{isBulkAction ? (
<Trans
count={documents.length}
defaults="Are you sure you want to archive these <em>{{ count }} documents</em>? They will be removed from collections and search results."
values={{ count: documents.length }}
components={{ em: <strong /> }}
/>
) : (
<Trans defaults="Archiving this document will remove it from the collection and search results." />
)}
</Text>
<Flex justify="flex-end" gap={8}>
<Button type="submit">
{isArchiving ? `${t("Archiving")}` : t("Archive")}
</Button>
</Flex>
</form>
);
}
export default observer(DocumentArchive);
+78 -196
View File
@@ -11,129 +11,77 @@ import useStores from "~/hooks/useStores";
import {
collectionPath,
documentPath,
homePath,
settingsPath,
} from "~/utils/routeHelpers";
type Props = {
documents: Document[];
onSubmit?: () => void;
document: Document;
onSubmit: () => void;
};
function DocumentDelete({ documents, onSubmit }: Props) {
function DocumentDelete({ document, onSubmit }: Props) {
const { t } = useTranslation();
const {
ui,
dialogs,
documents: documentsStore,
collections: collectionsStore,
userMemberships,
groupMemberships,
} = useStores();
const { ui, documents, collections, userMemberships, groupMemberships } =
useStores();
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const isBulkAction = documents.length > 1;
const canArchiveAll = documents.every(
(doc) => !doc.isDraft && !doc.isArchived && !doc.template
);
const nestedDocumentsCount = React.useMemo(
() =>
documents.reduce((total, doc) => {
const collection = collectionsStore.get(doc.collectionId || "");
const childrenCount = collection?.getChildrenForDocument(doc.id).length;
return total + (childrenCount ?? 0);
}, 0),
[documents, collectionsStore]
);
const canArchive =
!document.isDraft && !document.isArchived && !document.template;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setDeleting(true);
try {
const failedIds: string[] = [];
let successCount = 0;
await document.delete();
// Delete documents
for (const document of documents) {
try {
await document.delete();
userMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
groupMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
successCount++;
} catch {
failedIds.push(document.id);
userMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
groupMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
// only redirect if we're currently viewing the document that's deleted
if (ui.activeDocumentId === document.id) {
// If the document has a parent and it's available in the store then
// redirect to it
if (document.parentDocumentId) {
const parent = documents.get(document.parentDocumentId);
if (parent) {
history.push(documentPath(parent));
onSubmit();
return;
}
}
}
if (failedIds.length === documents.length) {
throw new Error(
t("Couldnt delete the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
}
onSubmit?.();
dialogs.closeAllModals();
// Show toast messages
if (isBulkAction) {
const message = failedIds.length
? t("{{ errorCount }} documents failed to delete, try again?", {
errorCount: failedIds.length,
})
: t("{{ count }} documents deleted", { count: successCount });
failedIds.length ? toast.warning(message) : toast.success(message);
} else {
toast.success(t("Document deleted"));
}
// only redirect if we're currently viewing one of the documents that have been deleted
const activeDocument = documents.find(
(doc) => ui.activeDocumentId === doc.id
);
if (activeDocument && !failedIds.includes(activeDocument.id)) {
const parent = activeDocument.parentDocumentId
? documentsStore.get(activeDocument.parentDocumentId)
: null;
const path = parent
? documentPath(parent)
: activeDocument.template
? settingsPath("templates")
: collectionPath(
collectionsStore.get(activeDocument.collectionId || "")
?.path || "/"
);
// If template, redirect to the template settings.
// Otherwise redirect to the collection (or) home.
const path = document.template
? settingsPath("templates")
: collection
? collectionPath(collection)
: homePath();
history.push(path);
}
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setDeleting(false);
}
},
[
documents,
userMemberships,
groupMemberships,
ui,
documentsStore,
collectionsStore,
history,
dialogs,
onSubmit,
isBulkAction,
t,
]
[onSubmit, ui, document, documents, history, collection]
);
const handleArchive = React.useCallback(
@@ -142,134 +90,68 @@ function DocumentDelete({ documents, onSubmit }: Props) {
setArchiving(true);
try {
const results = await Promise.allSettled(
documents.map((doc) => doc.archive())
);
const errorCount = results.filter(
(r) => r.status === "rejected"
).length;
if (errorCount === documents.length) {
throw new Error(
t("Couldnt archive the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
}
onSubmit?.();
dialogs.closeAllModals();
// Show toast messages
if (isBulkAction) {
const successCount = results.filter(
(r) => r.status === "fulfilled"
).length;
const message = errorCount
? t("{{ successCount }} archived, {{ errorCount }} failed", {
successCount,
errorCount,
})
: t("{{ count }} documents archived", { count: successCount });
errorCount ? toast.warning(message) : toast.success(message);
} else {
toast.success(t("Document archived"));
}
await document.archive();
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setArchiving(false);
}
},
[documents, dialogs, isBulkAction, t, onSubmit]
[onSubmit, document]
);
const NoChildBody = () =>
isBulkAction ? (
<Trans
count={documents.length}
defaults="Are you sure you want to delete these <em>{{ count }} documents</em>? This action will delete all their history."
values={{ count: documents.length }}
components={{ em: <strong /> }}
/>
) : (
<Trans
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
values={{
documentTitle: documents[0].titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
);
const HasChildBody = () =>
isBulkAction ? (
<Trans
count={documents.length}
defaults="Are you sure about that? Deleting these <em>{{ count }} documents</em> will delete all their history and their combined <em>{{ any }} nested documents.</em>."
values={{ count: documents.length, any: nestedDocumentsCount }}
components={{
em: <strong />,
}}
/>
) : (
<Trans
count={nestedDocumentsCount}
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>."
values={{
documentTitle: documents[0].titleWithDefault,
any: nestedDocumentsCount,
}}
components={{
em: <strong />,
}}
/>
);
const ArchiveInsteadBody = () =>
isBulkAction ? (
<Trans>
If youd like the option of referencing or restoring these documents in
the future, consider archiving them instead.
</Trans>
) : (
<Trans>
If youd like the option of referencing or restoring the document in the
future, consider archiving it instead.
</Trans>
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{!isBulkAction && documents[0].isTemplate ? (
{document.isTemplate ? (
<Trans
defaults="Are you sure you want to delete the <em>{{ documentTitle }}</em> template?"
values={{
documentTitle: documents[0].titleWithDefault,
documentTitle: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
) : nestedDocumentsCount < 1 ? (
<NoChildBody />
<Trans
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
values={{
documentTitle: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
) : (
<HasChildBody />
<Trans
count={nestedDocumentsCount}
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>."
values={{
documentTitle: document.titleWithDefault,
any: nestedDocumentsCount,
}}
components={{
em: <strong />,
}}
/>
)}
</Text>
{canArchiveAll && (
{canArchive && (
<Text as="p" type="secondary">
<ArchiveInsteadBody />
<Trans>
If youd like the option of referencing or restoring the{" "}
{{
noun: document.noun,
}}{" "}
in the future, consider archiving it instead.
</Trans>
</Text>
)}
<Flex justify="flex-end" gap={8}>
{canArchiveAll && (
{canArchive && (
<Button type="button" onClick={handleArchive} neutral>
{isArchiving ? `${t("Archiving")}` : t("Archive")}
</Button>
+37 -102
View File
@@ -14,38 +14,23 @@ import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
type Props = {
documents: Document[];
onSubmit?: () => void;
document: Document;
};
function DocumentMove({ documents, onSubmit }: Props) {
function DocumentMove({ document }: Props) {
const { dialogs, policies } = useStores();
const { t } = useTranslation();
const collectionTrees = useCollectionTrees();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const [isMoving, setMoving] = useState(false);
const isBulkAction = documents.length > 1;
const documentIds = useMemo(
() => new Set(documents.map((doc) => doc.id)),
[documents]
);
const items = useMemo(() => {
// Recursively filter out the document itself and its existing parent doc, if any.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter((c) => {
// if multiple documents are selected we want to only filter out the selected documents.
if (isBulkAction) {
return !documentIds.has(c.id);
}
return (
c.id !== documents[0].id && c.id !== documents[0].parentDocumentId
);
})
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
.map(filterSourceDocument),
});
@@ -60,14 +45,19 @@ function DocumentMove({ documents, onSubmit }: Props) {
// If the document we're moving is a template, only show collections as
// move targets.
const hasTemplates = documents.some((doc) => doc.isTemplate);
if (hasTemplates) {
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, documentIds, documents, isBulkAction]);
}, [
policies,
collectionTrees,
document.id,
document.parentDocumentId,
document.isTemplate,
]);
const move = async () => {
if (!selectedPath) {
@@ -75,101 +65,46 @@ function DocumentMove({ documents, onSubmit }: Props) {
return;
}
setMoving(true);
try {
const { type, id: parentDocumentId } = selectedPath;
const { type, id: parentDocumentId } = selectedPath;
const collectionId = selectedPath.collectionId as string;
const collectionId = selectedPath.collectionId as string;
let successCount = 0;
let errorCount = 0;
for (const document of documents) {
try {
if (type === "document") {
await document.move({ collectionId, parentDocumentId });
} else {
await document.move({ collectionId });
}
successCount++;
} catch {
errorCount++;
}
}
if (errorCount === documents.length) {
toast.error(
t("Couldnt move the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
setMoving(false);
return;
}
onSubmit?.();
if (!isBulkAction) {
toast.success(t("Document moved"));
} else {
if (errorCount === 0) {
toast.success(
t("{{ count }} documents moved", { count: successCount })
);
if (type === "document") {
await document.move({ collectionId, parentDocumentId });
} else {
toast.warning(
t("{{ errorCount }} documents failed to move, try again?", {
errorCount,
})
);
await document.move({ collectionId });
}
toast.success(t("Document moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the document, try again?"));
}
dialogs.closeAllModals();
setMoving(false);
};
const SelectedPathFooter = ({ title }: { title: string }) =>
isBulkAction ? (
<Trans
defaults="Move {{ count }} documents to <em>{{ location }}</em>"
values={{
count: documents.length,
location: title || t("Untitled"),
}}
components={{
em: <strong />,
}}
/>
) : (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: title || t("Untitled"),
}}
components={{
em: <strong />,
}}
/>
);
const NoSelectedPathFooter = isBulkAction
? t("Select a location to move {{ count }} documents", {
count: documents.length,
})
: t("Select a location to move");
return (
<FlexContainer column>
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
<SelectedPathFooter title={selectedPath.title} />
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title || t("Untitled"),
}}
components={{
em: <strong />,
}}
/>
) : (
NoSelectedPathFooter
t("Select a location to move")
)}
</StyledText>
<Button disabled={!selectedPath || isMoving} onClick={move}>
{isMoving ? `${t("Moving")}` : t("Move")}
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
</Button>
</Footer>
</FlexContainer>
+20 -15
View File
@@ -10,6 +10,7 @@ import UsersStore, { queriedUsers } from "~/stores/UsersStore";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import { ConditionalFade } from "~/components/Fade";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
@@ -21,6 +22,7 @@ import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { ExportCSV } from "./components/ExportCSV";
import { MembersTable } from "./components/MembersTable";
import { StickyFilters } from "./components/StickyFilters";
import UserRoleFilter from "./components/UserRoleFilter";
@@ -144,21 +146,24 @@ function Members() {
{{ signinMethods: team.signinMethods }} but havent signed in yet.
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
<StickyFilters gap={8} justify="space-between">
<Flex gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
</Flex>
<ExportCSV reqParams={reqParams} />
</StickyFilters>
<ConditionalFade animate={!data}>
<MembersTable
@@ -0,0 +1,86 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import { CSVHelper } from "@shared/utils/csv";
import download from "~/utils/download";
import useStores from "~/hooks/useStores";
import usePolicy from "~/hooks/usePolicy";
import useCurrentTeam from "~/hooks/useCurrentTeam";
type Props = {
/** Request parameters for filtering users */
reqParams: {
query?: string;
filter?: string;
role?: string;
sort?: string;
direction?: "ASC" | "DESC";
};
};
/**
* A button that exports all members to a CSV file.
*/
export function ExportCSV({ reqParams }: Props) {
const { t } = useTranslation();
const { users } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const [isExporting, setIsExporting] = useState(false);
const handleExportCSV = useCallback(async () => {
setIsExporting(true);
try {
const allUsers = await users.fetchAll({
...reqParams,
limit: 100,
});
// Convert to CSV format with formatted dates
const csvData = allUsers.map((user) => ({
id: user.id,
name: user.name,
email: user.email || "",
role: user.role,
lastActiveAt: user.lastActiveAt
? new Date(user.lastActiveAt).toISOString()
: "",
createdAt: user.createdAt ? new Date(user.createdAt).toISOString() : "",
}));
const headers: (keyof (typeof csvData)[0])[] = [
"id",
"name",
"email",
"role",
"lastActiveAt",
"createdAt",
];
const csv = CSVHelper.convertToCSV(csvData, headers);
// Trigger download
download(csv, "members.csv", "text/csv");
toast.success(t("Members exported successfully"));
} catch {
toast.error(t("Failed to export members"));
} finally {
setIsExporting(false);
}
}, [users, reqParams, t]);
if (!can.createExport) {
return null;
}
return (
<Button
type="button"
onClick={handleExportCSV}
disabled={isExporting}
neutral
>
{isExporting ? t("Exporting") + "…" : t("Download CSV")}
</Button>
);
}
+2 -2
View File
@@ -40,7 +40,7 @@ function SharedCollection({ collection }: Props) {
as={Link}
icon={<EditIcon />}
to={{
pathname: collectionPath(collection.path, "overview"),
pathname: collectionPath(collection, "overview"),
}}
neutral
>
@@ -83,7 +83,7 @@ function SharedCollection({ collection }: Props) {
</SharedMeta>
) : null}
</Flex>
<Overview collection={collection} shareId={shareId} />
<Overview collection={collection} readOnly />
</CenteredContent>
</Scene>
);
-72
View File
@@ -52,14 +52,6 @@ export default class DocumentsStore extends Store<Document> {
@observable
movingDocumentId: string | null | undefined;
/** Set of selected document IDs for bulk operations */
@observable
selectedIds: Set<string> = new Set();
/** Whether selection mode is active */
@observable
isSelectionMode = false;
importFileTypes: string[] = [
".md",
".doc",
@@ -780,68 +772,4 @@ export default class DocumentsStore extends Store<Document> {
? this.rootStore.collections.get(document.collectionId)
: undefined;
}
// Selection methods for bulk operations
/**
* Returns an array of selected document IDs.
*/
@computed
get selectedDocumentIds(): string[] {
return Array.from(this.selectedIds);
}
/**
* Returns the selected documents.
*/
@computed
get selectedDocuments(): Document[] {
return compact(this.selectedDocumentIds.map((id) => this.get(id)));
}
/**
* Checks if a document is selected.
*
* @param id - the document id to check.
* @returns true if the document is selected.
*/
isSelected(id: string): boolean {
return this.selectedIds.has(id);
}
/**
* Selects a document.
*
* @param id - the document id to select.
*/
@action
select(id: string): void {
this.selectedIds.add(id);
void this.fetch(id);
if (!this.isSelectionMode) {
this.isSelectionMode = true;
}
}
/**
* Deselects a document.
*
* @param id - the document id to deselect.
*/
@action
deselect(id: string): void {
this.selectedIds.delete(id);
if (this.selectedIds.size === 0) {
this.isSelectionMode = false;
}
}
/**
* Clears all selections and exits selection mode.
*/
@action
clearSelection(): void {
this.selectedIds.clear();
this.isSelectionMode = false;
}
}
+4 -6
View File
@@ -15,7 +15,7 @@ describe("generateEmojiNameFromFilename", () => {
);
});
test("should replace spaces with underscores", () => {
test("should replace spaces and dashes with underscores", () => {
expect(generateEmojiNameFromFilename("party parrot.gif")).toBe(
"party_parrot"
);
@@ -26,7 +26,7 @@ describe("generateEmojiNameFromFilename", () => {
test("should remove invalid characters", () => {
expect(generateEmojiNameFromFilename("party-parrot.gif")).toBe(
"partyparrot"
"party_parrot"
);
expect(generateEmojiNameFromFilename("happy!@#$%.png")).toBe("happy");
expect(generateEmojiNameFromFilename("emoji(1).png")).toBe("emoji");
@@ -57,9 +57,7 @@ describe("generateEmojiNameFromFilename", () => {
expect(generateEmojiNameFromFilename("party___parrot.gif")).toBe(
"party_parrot"
);
expect(generateEmojiNameFromFilename("test__emoji.png")).toBe(
"test_emoji"
);
expect(generateEmojiNameFromFilename("test__emoji.png")).toBe("test_emoji");
});
test("should handle complex filenames", () => {
@@ -67,7 +65,7 @@ describe("generateEmojiNameFromFilename", () => {
"party_parrot"
);
expect(generateEmojiNameFromFilename("dumpster-fire-2023.png")).toBe(
"dumpsterfire"
"dumpster_fire"
);
});
+6 -3
View File
@@ -6,7 +6,7 @@ export function emojiToUrl(text: string) {
* Generates a valid emoji name from a filename by:
* - Removing file extension
* - Converting to lowercase
* - Replacing spaces with underscores
* - Replacing spaces and dashes with underscores
* - Removing invalid characters (only allowing lowercase letters and underscores)
* - Removing numbers
* - Removing leading/trailing underscores
@@ -18,8 +18,11 @@ export function generateEmojiNameFromFilename(filename: string): string {
// Remove file extension
const nameWithoutExt = filename.replace(/\.[^.]+$/, "");
// Convert to lowercase, replace spaces with underscores
let name = nameWithoutExt.toLowerCase().replace(/\s+/g, "_");
// Convert to lowercase, replace spaces and dashes with underscores
let name = nameWithoutExt
.toLowerCase()
.replace(/\s+/g, "_")
.replace(/-+/g, "_");
// Remove all characters that aren't lowercase letters or underscores (including numbers)
name = name.replace(/[^a-z_]/g, "");
+2 -8
View File
@@ -14,20 +14,14 @@ export const isURLMentionable = ({
integration: Integration;
}): boolean => {
const { hostname, pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "github.com" &&
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
);
return hostname === "github.com";
}
case IntegrationService.Linear: {
const pathParts = pathname.split("/");
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
+15 -6
View File
@@ -1,7 +1,7 @@
import queryString from "query-string";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import type Collection from "~/models/Collection";
import type Comment from "~/models/Comment";
import type Document from "~/models/Document";
import env from "~/env";
export function homePath(): string {
@@ -37,11 +37,18 @@ export function commentPath(document: Document, comment: Comment): string {
}`;
}
export function collectionPath(url: string, section?: string): string {
export function collectionPath(
collection: Collection,
section?: string
): string {
if (section) {
return `${url}/${section}`;
return `${collection.path}/${section}`;
}
return url;
return collection.path;
}
export function collectionEditPath(collection: Collection): string {
return collectionPath(collection, "overview/edit");
}
export function updateCollectionPath(
@@ -144,6 +151,8 @@ export function urlify(path: string): string {
export const matchCollectionSlug =
":collectionSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
export const matchCollectionEdit = `/collection/${matchCollectionSlug}/overview/edit`;
export const matchDocumentSlug =
":documentSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
+10 -10
View File
@@ -51,11 +51,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.927.0",
"@aws-sdk/lib-storage": "3.927.0",
"@aws-sdk/s3-presigned-post": "3.927.0",
"@aws-sdk/s3-request-presigner": "3.927.0",
"@aws-sdk/signature-v4-crt": "^3.927.0",
"@aws-sdk/client-s3": "3.946.0",
"@aws-sdk/lib-storage": "3.946.0",
"@aws-sdk/s3-presigned-post": "3.946.0",
"@aws-sdk/s3-request-presigner": "3.946.0",
"@aws-sdk/signature-v4-crt": "^3.946.0",
"@babel/core": "^7.28.5",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
@@ -136,7 +136,7 @@
"es6-error": "^4.1.1",
"fast-deep-equal": "^3.1.3",
"fetch-retry": "^5.0.6",
"form-data": "^4.0.4",
"form-data": "^4.0.5",
"fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
"franc": "^6.2.0",
@@ -182,7 +182,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^7.0.11",
"octokit": "^3.2.2",
"outline-icons": "^3.13.1",
"outline-icons": "^3.15.0",
"oy-vey": "^0.12.1",
"pako": "^2.1.0",
"passport": "^0.7.0",
@@ -200,7 +200,7 @@
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.5.0",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-markdown": "^1.13.2",
"prosemirror-model": "^1.25.4",
@@ -353,10 +353,10 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.38.30",
"discord-api-types": "^0.38.36",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"ioredis-mock": "^8.9.0",
"ioredis-mock": "^8.13.1",
"jest-cli": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
+13 -1
View File
@@ -4,6 +4,7 @@ import {
type OAuthWebFlowAuthOptions,
type InstallationAuthOptions,
} from "@octokit/auth-app";
import { Sequelize } from "sequelize";
import { Endpoints, OctokitResponse } from "@octokit/types";
import { Octokit } from "octokit";
import pluralize from "pluralize";
@@ -229,11 +230,22 @@ export class GitHub {
return;
}
// Find integration, prioritizing one where the installation account matches the resource owner
const integration = (await Integration.findOne({
where: {
service: IntegrationService.GitHub,
teamId: actor.teamId,
"settings.github.installation.account.name": resource.owner,
},
order: [
[
Sequelize.literal(
`CASE WHEN "settings"->'github'->'installation'->'account'->>'name' = :owner THEN 0 ELSE 1 END`
),
"ASC",
],
],
replacements: {
owner: resource.owner,
},
})) as Integration<IntegrationType.Embed>;
@@ -0,0 +1,55 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("mentions", {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
},
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "documents",
},
},
mentionedUserId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
},
},
mentionType: {
type: Sequelize.STRING,
allowNull: false,
},
mentionId: {
type: Sequelize.STRING,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addIndex("mentions", ["mentionedUserId"]);
await queryInterface.addIndex("mentions", ["documentId"]);
await queryInterface.addIndex("mentions", ["mentionId", "mentionType"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("mentions");
},
};
@@ -0,0 +1,16 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addIndex("authentications", ["teamId", "service"], {
name: "authentications_team_id_service",
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeIndex(
"authentications",
"authentications_team_id_service"
);
},
};
@@ -0,0 +1,27 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// Add composite index on service and type for better filtering
await queryInterface.addIndex("integrations", ["service", "type"], {
name: "integrations_service_type",
});
// Add GIN index on settings for JSONB queries
// Using raw SQL as Sequelize doesn't support GIN index type natively
await queryInterface.sequelize.query(
'CREATE INDEX "integrations_settings_gin" ON "integrations" USING GIN ("settings");'
);
},
async down(queryInterface, Sequelize) {
// Drop indexes in reverse order of creation
await queryInterface.sequelize.query(
'DROP INDEX IF EXISTS "integrations_settings_gin";'
);
await queryInterface.removeIndex(
"integrations",
"integrations_service_type"
);
},
};
+4
View File
@@ -62,6 +62,7 @@ import Group from "./Group";
import GroupMembership from "./GroupMembership";
import GroupUser from "./GroupUser";
import Import from "./Import";
import Mention from "./Mention";
import Relationship from "./Relationship";
import Revision from "./Revision";
import Star from "./Star";
@@ -668,6 +669,9 @@ class Document extends ArchivableModel<
@HasMany(() => Relationship)
relationships: Relationship[];
@HasMany(() => Mention)
mentions: Mention[];
@HasMany(() => Star)
starred: Star[];
+104
View File
@@ -0,0 +1,104 @@
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
DataType,
BelongsTo,
ForeignKey,
Column,
Table,
} from "sequelize-typescript";
import { MentionType } from "@shared/types";
import Document from "./Document";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
@Table({ tableName: "mentions", modelName: "mention" })
@Fix
class Mention extends IdModel<
InferAttributes<Mention>,
Partial<InferCreationAttributes<Mention>>
> {
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => User, "mentionedUserId")
mentionedUser: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
mentionedUserId: string;
@Column(DataType.STRING)
mentionType: MentionType;
@Column(DataType.STRING)
mentionId: string;
/**
* Find all mentions for a user in documents they have access to
*
* @param userId The user ID to find mentions for
* @param user The user to check document access for
*/
public static async findMentionsForUser(userId: string, user: User) {
// Lazy import to avoid circular dependency
const { can } = await import("@server/policies");
const mentions = await this.findAll({
where: {
mentionedUserId: userId,
},
include: [
{
model: Document,
as: "document",
},
],
});
// Filter mentions to only include documents the user has access to
const accessibleMentions = [];
for (const mention of mentions) {
if (mention.document) {
const hasAccess = can(user, "read", mention.document);
if (hasAccess) {
accessibleMentions.push(mention);
}
}
}
return accessibleMentions;
}
/**
* Find all mentions in a specific document
*
* @param documentId The document ID to find mentions for
*/
public static async findMentionsInDocument(documentId: string) {
return this.findAll({
where: {
documentId,
},
include: [
{
model: User,
as: "mentionedUser",
},
],
});
}
}
export default Mention;
+4
View File
@@ -57,6 +57,7 @@ import Attachment from "./Attachment";
import AuthenticationProvider from "./AuthenticationProvider";
import Collection from "./Collection";
import Group from "./Group";
import Mention from "./Mention";
import Team from "./Team";
import UserAuthentication from "./UserAuthentication";
import UserMembership from "./UserMembership";
@@ -248,6 +249,9 @@ class User extends ParanoidModel<
@HasMany(() => UserAuthentication)
authentications: UserAuthentication[];
@HasMany(() => Mention, "mentionedUserId")
mentions: Mention[];
// getters
get isSuspended(): boolean {
+2
View File
@@ -32,6 +32,8 @@ export { default as Integration } from "./Integration";
export { default as IntegrationAuthentication } from "./IntegrationAuthentication";
export { default as Mention } from "./Mention";
export { default as Notification } from "./Notification";
export { default as OAuthAuthentication } from "./oauth/OAuthAuthentication";
+98 -43
View File
@@ -1,6 +1,4 @@
import flattenDeep from "lodash/flattenDeep";
import isObject from "lodash/isPlainObject";
import uniq from "lodash/uniq";
import { Model } from "sequelize-typescript";
import { AuthorizationError } from "@server/errors";
@@ -26,8 +24,6 @@ type Ability = {
* This is originally adapted from https://www.npmjs.com/package/cancan
*/
export class CanCan {
public abilities: Ability[] = [];
/**
* Define an authorized ability for a model, action, and target.
*
@@ -58,7 +54,17 @@ export class CanCan {
(this.toArray(actions) as string[]).forEach((action) => {
(this.toArray(targets) as T[]).forEach((target) => {
this.abilities.push({ model, action, target, condition } as Ability);
const ability = { model, action, target, condition } as Ability;
// Add to index
if (!this.abilities.has(model)) {
this.abilities.set(model, new Map());
}
const actionMap = this.abilities.get(model)!;
if (!actionMap.has(action)) {
actionMap.set(action, []);
}
actionMap.get(action)!.push(ability);
});
});
};
@@ -85,25 +91,31 @@ export class CanCan {
);
// Check conditions only for matching abilities
const conditions = uniq(
flattenDeep(
matchingAbilities.map((ability) => {
if (!ability.condition) {
return false;
}
return ability.condition(performer, target, options || {});
})
)
);
const seenConditions = new Set<boolean | string>();
const membershipIds: string[] = [];
let hasNonMembershipMatch = false;
const matchingConditions = conditions.filter(Boolean);
const matchingMembershipIds = matchingConditions.filter(
(m) => typeof m === "string"
) as string[];
for (const ability of matchingAbilities) {
if (!ability.condition) {
continue;
}
return matchingMembershipIds.length > 0
? matchingMembershipIds
: matchingConditions.length > 0;
const result = ability.condition(performer, target, options);
if (!result || seenConditions.has(result)) {
continue;
}
seenConditions.add(result);
if (typeof result === "string") {
membershipIds.push(result);
} else {
hasNonMembershipMatch = true;
}
}
return membershipIds.length > 0 ? membershipIds : hasNonMembershipMatch;
};
/*
@@ -113,22 +125,31 @@ export class CanCan {
*/
public serialize = (performer: Model, target: Model | null): Policy => {
const output: Record<string, boolean | string[]> = {};
abilities.forEach((ability) => {
if (
performer instanceof ability.model &&
target instanceof (ability.target as any)
) {
let response: boolean | string[] = true;
try {
response = this.can(performer, ability.action, target);
} catch (_err) {
response = false;
// Get all unique actions to check from the index
const actionsToCheck = new Set<string>();
for (const [model, actionMap] of this.abilities.entries()) {
if (performer instanceof model) {
for (const [action, abilities] of actionMap.entries()) {
for (const ability of abilities) {
if (target instanceof (ability.target as any)) {
actionsToCheck.add(action);
break;
}
}
}
}
}
output[ability.action] = response;
// Check each unique action once
actionsToCheck.forEach((action) => {
try {
output[action] = this.can(performer, action, target);
} catch (_err) {
output[action] = false;
}
});
return output;
};
@@ -174,15 +195,49 @@ export class CanCan {
performer: Model,
action: string,
target: Model | null | undefined
) =>
this.abilities.filter(
(ability) =>
performer instanceof ability.model &&
(ability.target === "all" ||
target === ability.target ||
target instanceof (ability.target as any)) &&
(ability.action === "manage" || action === ability.action)
);
) => {
const matchingAbilities: Ability[] = [];
// Use index to find abilities by model and action
for (const [model, actionMap] of this.abilities.entries()) {
if (!(performer instanceof model)) {
continue;
}
// Check for specific action
const specificAbilities = actionMap.get(action);
if (specificAbilities) {
for (const ability of specificAbilities) {
if (
ability.target === "all" ||
target === ability.target ||
target instanceof (ability.target as any)
) {
matchingAbilities.push(ability);
}
}
}
// Check for "manage" action (applies to all actions)
const manageAbilities = actionMap.get("manage");
if (manageAbilities) {
for (const ability of manageAbilities) {
if (
ability.target === "all" ||
target === ability.target ||
target instanceof (ability.target as any)
) {
matchingAbilities.push(ability);
}
}
}
}
return matchingAbilities;
};
// Index for fast lookups: Map<model, Map<action, Ability[]>>
private abilities: Map<Constructor, Map<string, Ability[]>> = new Map();
private get = <T extends object>(obj: T, key: keyof T) =>
"get" in obj && typeof obj.get === "function" ? obj.get(key) : obj[key];
@@ -219,7 +274,7 @@ export class CanCan {
const cancan = new CanCan();
export const { allow, can, cannot, abilities, serialize } = cancan;
export const { allow, can, cannot, serialize } = cancan;
// This is exported separately as a workaround for the following issue:
// https://github.com/microsoft/TypeScript/issues/36931
+18 -9
View File
@@ -1,5 +1,4 @@
import invariant from "invariant";
import filter from "lodash/filter";
import { CollectionPermission } from "@shared/types";
import { Collection, User, Team } from "@server/models";
import { allow } from "./cancan";
@@ -7,10 +6,10 @@ import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createCollection", Team, (actor, team) =>
and(
isTeamModel(actor, team),
isTeamMutable(actor),
!actor.isGuest,
!actor.isViewer,
isTeamModel(actor, team),
isTeamMutable(actor),
or(actor.isAdmin, !!team?.memberCollectionCreate)
)
);
@@ -26,9 +25,9 @@ allow(User, "importCollection", Team, (actor, team) =>
allow(User, "move", Collection, (actor, collection) =>
and(
//
!!collection?.isActive,
isTeamAdmin(actor, collection),
isTeamMutable(actor),
!!collection?.isActive
isTeamMutable(actor)
)
);
@@ -194,10 +193,20 @@ function includesMembership(
"Development: collection groupMemberships not preloaded, did you forget `withMembership` scope?"
);
const membershipIds = filter(
[...collection.memberships, ...collection.groupMemberships],
(m) => permissions.includes(m.permission as CollectionPermission)
).map((m) => m.id);
const permissionSet = new Set(permissions);
const membershipIds: string[] = [];
for (const membership of collection.memberships) {
if (permissionSet.has(membership.permission as CollectionPermission)) {
membershipIds.push(membership.id);
}
}
for (const membership of collection.groupMemberships) {
if (permissionSet.has(membership.permission as CollectionPermission)) {
membershipIds.push(membership.id);
}
}
return membershipIds.length > 0 ? membershipIds : false;
}
+45 -31
View File
@@ -1,5 +1,4 @@
import invariant from "invariant";
import filter from "lodash/filter";
import { DocumentPermission, TeamPreference } from "@shared/types";
import { Document, Revision, User, Team } from "@server/models";
import { allow, cannot, can } from "./cancan";
@@ -36,8 +35,8 @@ allow(User, "read", Document, (actor, document) =>
allow(User, ["listRevisions", "listViews"], Document, (actor, document) =>
or(
and(can(actor, "read", document), !actor.isGuest),
and(can(actor, "update", document), actor.isGuest)
and(!actor.isGuest, can(actor, "read", document)),
and(actor.isGuest, can(actor, "update", document))
)
);
@@ -53,14 +52,14 @@ allow(User, "download", Document, (actor, document) =>
allow(User, "comment", Document, (actor, document) =>
and(
// TODO: We'll introduce a separate permission for commenting
or(
and(can(actor, "read", document), !actor.isGuest),
and(can(actor, "update", document), actor.isGuest)
),
isTeamMutable(actor),
!!document?.isActive,
!document?.template,
isTeamMutable(actor),
// TODO: We'll introduce a separate permission for commenting
or(
and(!actor.isGuest, can(actor, "read", document)),
and(actor.isGuest, can(actor, "update", document))
),
or(!document?.collection, document?.collection?.commenting !== false)
)
);
@@ -72,26 +71,26 @@ allow(
(actor, document) =>
and(
//
can(actor, "read", document),
!document?.template
!document?.template,
can(actor, "read", document)
)
);
allow(User, "share", Document, (actor, document) =>
and(
can(actor, "read", document),
isTeamMutable(actor),
!!document?.isActive,
!document?.template,
isTeamMutable(actor),
can(actor, "read", document),
or(!document?.collection, can(actor, "share", document?.collection))
)
);
allow(User, "update", Document, (actor, document) =>
and(
can(actor, "read", document),
isTeamMutable(actor),
!!document?.isActive,
isTeamMutable(actor),
can(actor, "read", document),
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
@@ -115,8 +114,8 @@ allow(User, "update", Document, (actor, document) =>
allow(User, "publish", Document, (actor, document) =>
and(
//
can(actor, "update", document),
!!document?.isDraft
!!document?.isDraft,
can(actor, "update", document)
)
);
@@ -171,35 +170,40 @@ allow(User, "move", Document, (actor, document) =>
);
allow(User, "createChildDocument", Document, (actor, document) =>
and(can(actor, "update", document), !document?.isDraft, !document?.template)
and(
//
!document?.isDraft,
!document?.template,
can(actor, "update", document)
)
);
allow(User, ["updateInsights", "pin", "unpin"], Document, (actor, document) =>
and(
can(actor, "update", document),
can(actor, "update", document?.collection),
!document?.isDraft,
!document?.template,
!actor.isGuest
!actor.isGuest,
can(actor, "update", document),
can(actor, "update", document?.collection)
)
);
allow(User, "pinToHome", Document, (actor, document) =>
and(
//
isTeamAdmin(actor, document),
isTeamMutable(actor),
!document?.isDraft,
!document?.template,
!!document?.isActive
!!document?.isActive,
isTeamAdmin(actor, document),
isTeamMutable(actor)
)
);
allow(User, "delete", Document, (actor, document) =>
and(
!document?.isDeleted,
isTeamModel(actor, document),
isTeamMutable(actor),
!document?.isDeleted,
or(
can(actor, "unarchive", document),
can(actor, "update", document),
@@ -210,9 +214,9 @@ allow(User, "delete", Document, (actor, document) =>
allow(User, "restore", Document, (actor, document) =>
and(
isTeamModel(actor, document),
!actor.isGuest,
!!document?.isDeleted,
isTeamModel(actor, document),
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
@@ -231,9 +235,9 @@ allow(User, "restore", Document, (actor, document) =>
allow(User, "permanentDelete", Document, (actor, document) =>
and(
isTeamModel(actor, document),
!actor.isGuest,
!!document?.isDeleted,
isTeamModel(actor, document),
isTeamAdmin(actor, document)
)
);
@@ -322,10 +326,20 @@ function includesMembership(
"Development: document groupMemberships should be preloaded, did you forget withMembership scope?"
);
const membershipIds = filter(
[...document.memberships, ...document.groupMemberships],
(m) => permissions.includes(m.permission as DocumentPermission)
).map((m) => m.id);
const permissionSet = new Set(permissions);
const membershipIds: string[] = [];
for (const membership of document.memberships) {
if (permissionSet.has(membership.permission as DocumentPermission)) {
membershipIds.push(membership.id);
}
}
for (const membership of document.groupMemberships) {
if (permissionSet.has(membership.permission as DocumentPermission)) {
membershipIds.push(membership.id);
}
}
return membershipIds.length > 0 ? membershipIds : false;
}
+6 -2
View File
@@ -7,8 +7,12 @@ import invariant from "invariant";
type Args = boolean | string | Args[];
export function and(...args: Args[]) {
const filtered = args.filter(Boolean);
return filtered.length === args.length ? filtered : false;
for (const arg of args) {
if (!arg) {
return false;
}
}
return args;
}
export function or(...args: Args[]) {
+2 -2
View File
@@ -14,7 +14,7 @@ type Options = {
includeText?: boolean;
/** Always include the data of the document in the payload. */
includeData?: boolean;
/** Include the updatedAt timestamp for public documents. */
includeUpdatedAt?: boolean;
};
@@ -56,7 +56,6 @@ async function presentDocument(
text,
icon: document.icon,
color: document.color,
tasks: document.tasks,
language: document.language,
createdAt: document.createdAt,
createdBy: undefined,
@@ -85,6 +84,7 @@ async function presentDocument(
if (!options.isPublic) {
const source = await document.$get("import");
res.tasks = document.tasks;
res.isCollectionDeleted = await document.isCollectionDeleted();
res.collectionId = document.collectionId;
res.parentDocumentId = document.parentDocumentId;
@@ -0,0 +1,232 @@
import { v4 as uuidv4 } from "uuid";
import { parser } from "@server/editor";
import { Mention } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import MentionsProcessor from "./MentionsProcessor";
describe("MentionsProcessor", () => {
it("should create new mention records", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const mentionId = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(1);
});
it("should not create mention records for unpublished documents", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const mentionId = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
publishedAt: null,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.update",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
createdAt: new Date().toISOString(),
actorId: user.id!,
data: {
title: document.title,
autosave: false,
done: true,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(0);
});
it("should update mention records when document is updated", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const anotherMentionedUser = await buildUser({ teamId: user.teamId });
const mentionId1 = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId1}/user/${mentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
// Update document to mention a different user
const mentionId2 = uuidv4();
const newText = `Hello @[${anotherMentionedUser.name}](mention://${mentionId2}/user/${anotherMentionedUser.id})!`;
document.text = newText;
document.content = parser.parse(newText)?.toJSON() || document.content;
await document.save();
await processor.perform({
name: "documents.update",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
createdAt: new Date().toISOString(),
data: {
title: document.title,
autosave: false,
done: true,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(1);
expect(mentions[0].mentionedUserId).toBe(anotherMentionedUser.id);
});
it("should destroy removed mention records", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const anotherMentionedUser = await buildUser({ teamId: user.teamId });
const mentionId1 = uuidv4();
const mentionId2 = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId1}/user/${mentionedUser.id}) and @[${anotherMentionedUser.name}](mention://${mentionId2}/user/${anotherMentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
// Update document to remove one mention
const mentionId3 = uuidv4();
const newText = `Hello @[${mentionedUser.name}](mention://${mentionId3}/user/${mentionedUser.id})!`;
document.text = newText;
document.content = parser.parse(newText)?.toJSON() || document.content;
await document.save();
await processor.perform({
name: "documents.update",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
createdAt: new Date().toISOString(),
data: {
title: document.title,
autosave: false,
done: true,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(1);
expect(mentions[0].mentionedUserId).toBe(mentionedUser.id);
});
it("should destroy related mentions", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const mentionId = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
await processor.perform({
name: "documents.delete",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(0);
});
});
@@ -0,0 +1,119 @@
import { Op } from "sequelize";
import { MentionType } from "@shared/types";
import { Document, Mention, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { Event, DocumentEvent, RevisionEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class MentionsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.publish",
"documents.update",
"documents.delete",
];
async perform(event: DocumentEvent | RevisionEvent) {
switch (event.name) {
case "documents.publish": {
const document = await Document.findByPk(event.documentId);
if (!document) {
return;
}
const mentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
await Promise.all(
mentions.map(async (mention) => {
const mentionedUser = await User.findByPk(mention.modelId);
if (
!mentionedUser ||
mentionedUser.id === document.lastModifiedById
) {
return;
}
await Mention.findOrCreate({
where: {
documentId: document.id,
mentionedUserId: mentionedUser.id,
mentionId: mention.id,
mentionType: mention.type,
},
defaults: {
userId: document.lastModifiedById,
},
});
})
);
break;
}
case "documents.update": {
const document = await Document.findByPk(event.documentId);
if (!document) {
return;
}
// mentions are only created for published documents
if (!document.publishedAt) {
return;
}
const mentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
const mentionIds: string[] = [];
// create or find existing mention records for mentioned users
await Promise.all(
mentions.map(async (mention) => {
const mentionedUser = await User.findByPk(mention.modelId);
if (
!mentionedUser ||
mentionedUser.id === document.lastModifiedById
) {
return;
}
await Mention.findOrCreate({
where: {
documentId: document.id,
mentionedUserId: mentionedUser.id,
mentionId: mention.id,
mentionType: mention.type,
},
defaults: {
userId: document.lastModifiedById,
},
});
mentionIds.push(mention.id);
})
);
// delete any mentions that no longer exist
await Mention.destroy({
where: {
mentionId: {
[Op.notIn]: mentionIds,
},
documentId: event.documentId,
},
});
break;
}
case "documents.delete": {
await Mention.destroy({
where: {
documentId: event.documentId,
},
});
break;
}
default:
}
}
}
@@ -65,8 +65,9 @@ export default class UpdateDocumentsPopularityScoreTask extends CronTask {
const threshold = subWeeks(now, env.POPULARITY_ACTIVITY_THRESHOLD_WEEKS);
// Generate unique table name for this run to prevent conflicts
const uniqueId = crypto.randomBytes(8).toString("hex");
this.workingTable = `${WORKING_TABLE_PREFIX}_${uniqueId}`;
const dateStr = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
const uniqueId = crypto.randomBytes(4).toString("hex");
this.workingTable = `${WORKING_TABLE_PREFIX}_${dateStr}_${uniqueId}`;
try {
// Setup: Create working table and populate with active document IDs
@@ -150,31 +151,64 @@ export default class UpdateDocumentsPopularityScoreTask extends CronTask {
const [startUuid, endUuid] = this.getPartitionBounds(partition);
// Populate with documents that have recent activity and are valid
// (published, not deleted). Using JOINs to filter upfront.
await sequelize.query(
`
INSERT INTO ${this.workingTable} ("documentId")
SELECT DISTINCT d.id
FROM documents d
WHERE d."publishedAt" IS NOT NULL
AND d."deletedAt" IS NULL
AND (
EXISTS (
SELECT 1 FROM revisions r
WHERE r."documentId" = d.id AND r."createdAt" >= :threshold
// (published, not deleted). Process in chunks to avoid long-running queries.
let offset = 0;
let insertedCount = 0;
const chunkSize = 500;
while (true) {
const result = await sequelize.query<{ documentId: string }>(
`
INSERT INTO ${this.workingTable} ("documentId")
SELECT DISTINCT d.id as "documentId"
FROM documents d
WHERE d."publishedAt" IS NOT NULL
AND d."deletedAt" IS NULL
AND (
EXISTS (
SELECT 1 FROM revisions r
WHERE r."documentId" = d.id AND r."createdAt" >= :threshold
)
OR EXISTS (
SELECT 1 FROM comments c
WHERE c."documentId" = d.id AND c."createdAt" >= :threshold
)
OR EXISTS (
SELECT 1 FROM views v
WHERE v."documentId" = d.id AND v."updatedAt" >= :threshold
)
)
OR EXISTS (
SELECT 1 FROM comments c
WHERE c."documentId" = d.id AND c."createdAt" >= :threshold
)
OR EXISTS (
SELECT 1 FROM views v
WHERE v."documentId" = d.id AND v."updatedAt" >= :threshold
)
)
${startUuid && endUuid ? "AND d.id >= :startUuid AND d.id <= :endUuid" : ""}
`,
{ replacements: { threshold, startUuid, endUuid } }
${startUuid && endUuid ? "AND d.id >= :startUuid AND d.id <= :endUuid" : ""}
ORDER BY d.id
LIMIT :limit
OFFSET :offset
ON CONFLICT ("documentId") DO NOTHING
RETURNING "documentId"
`,
{
replacements: {
threshold,
startUuid,
endUuid,
limit: chunkSize,
offset,
},
type: QueryTypes.SELECT,
}
);
insertedCount += result.length;
if (result.length < chunkSize) {
break;
}
offset += chunkSize;
}
Logger.debug(
"task",
`Populated working table with ${insertedCount} documents in ${Math.ceil(insertedCount / chunkSize)} chunks`
);
// Create index on processed column for efficient batch selection
@@ -10,6 +10,7 @@ import {
buildComment,
buildCommentMark,
buildDocument,
buildEmoji,
buildResolvedComment,
buildTeam,
buildUser,
@@ -886,6 +887,72 @@ describe("#comments.add_reaction", () => {
]);
expect(addedReaction).toBeTruthy();
});
it("should add a custom emoji reaction to a comment", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const emoji = await buildEmoji({
teamId: team.id,
createdById: user.id,
});
const res = await server.post("/api/comments.add_reaction", {
body: {
token: user.getJwtToken(),
id: comment.id,
emoji: emoji.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
const updatedComment = await Comment.findByPk(comment.id);
const addedReaction = await Reaction.findOne({
where: { commentId: comment.id, emoji: emoji.id, userId: user.id },
});
expect(updatedComment?.reactions).toEqual([
{ emoji: emoji.id, userIds: [user.id] },
]);
expect(addedReaction).toBeTruthy();
});
it("should fail with custom emoji from different team", async () => {
const team = await buildTeam();
const otherTeam = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const emoji = await buildEmoji({
teamId: otherTeam.id,
});
const res = await server.post("/api/comments.add_reaction", {
body: {
token: user.getJwtToken(),
id: comment.id,
emoji: emoji.id,
},
});
expect(res.status).toEqual(403);
});
});
describe("#comments.remove_reaction", () => {
+10 -1
View File
@@ -5,14 +5,16 @@ import {
CommentStatusFilter,
TeamPreference,
MentionType,
IconType,
} from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import { parser } from "@server/editor";
import auth from "@server/middlewares/authentication";
import { feature } from "@server/middlewares/feature";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Document, Comment, Collection, Reaction } from "@server/models";
import { Document, Comment, Collection, Reaction, Emoji } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { authorize } from "@server/policies";
@@ -407,6 +409,13 @@ router.post(
authorize(user, "comment", document);
authorize(user, "addReaction", comment);
if (determineIconType(emoji) === IconType.Custom) {
const customEmoji = await Emoji.findByPk(emoji, {
transaction,
});
authorize(user, "read", customEmoji);
}
await Reaction.findOrCreate({
where: {
emoji,
+2 -2
View File
@@ -1,8 +1,8 @@
import emojiRegex from "emoji-regex";
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { CommentStatusFilter } from "@shared/types";
import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema";
import { zodEmojiType } from "@server/utils/zod";
const BaseIdSchema = z.object({
/** Comment Id */
@@ -104,7 +104,7 @@ export type CommentsUnresolveReq = z.infer<typeof CommentsUnresolveSchema>;
export const CommentsReactionSchema = z.object({
body: BaseIdSchema.extend({
/** Emoji that's added to (or) removed from a comment as a reaction. */
emoji: z.string().regex(emojiRegex()),
emoji: zodEmojiType(),
}),
});
+2
View File
@@ -469,6 +469,7 @@ router.post(
]),
required: true,
where: {
teamId: user.teamId,
collectionId: collectionIds,
},
include: [
@@ -522,6 +523,7 @@ router.post(
? [collectionId]
: await user.collectionIds();
const where: WhereOptions = {
teamId: user.teamId,
createdById: user.id,
collectionId: {
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
+1
View File
@@ -2,6 +2,7 @@ if (process.env.NODE_ENV !== "test") {
// oxlint-disable-next-line @typescript-eslint/no-var-requires
require("@dotenvx/dotenvx").config({
silent: true,
ignore: ["MISSING_ENV_FILE"],
});
}

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