From d6fcf44bf47d329cfca70b92e3a8a3876e9bd706 Mon Sep 17 00:00:00 2001 From: Prikshit Singh <99068054+PrikshitSingh24@users.noreply.github.com> Date: Fri, 27 Oct 2023 06:04:25 +0530 Subject: [PATCH 001/241] Add instagram embed (#6075) --- public/images/instagram.png | Bin 0 -> 14134 bytes shared/editor/embeds/Instagram.test.ts | 27 +++++++++++++++++++++++++ shared/editor/embeds/Instagram.tsx | 14 +++++++++++++ shared/editor/embeds/index.tsx | 7 +++++++ 4 files changed, 48 insertions(+) create mode 100644 public/images/instagram.png create mode 100644 shared/editor/embeds/Instagram.test.ts create mode 100644 shared/editor/embeds/Instagram.tsx diff --git a/public/images/instagram.png b/public/images/instagram.png new file mode 100644 index 0000000000000000000000000000000000000000..9d8e3119f6a4a1f762bab7df57eaa8f0117207c3 GIT binary patch literal 14134 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≈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 A%m4rY literal 0 HcmV?d00001 diff --git a/shared/editor/embeds/Instagram.test.ts b/shared/editor/embeds/Instagram.test.ts new file mode 100644 index 000000000000..707b48ff7371 --- /dev/null +++ b/shared/editor/embeds/Instagram.test.ts @@ -0,0 +1,27 @@ +import Instagram from "./Instagram"; + +describe("Instagram", () => { + const match = Instagram.ENABLED[0]; + + test("to be enabled on post link", () => { + expect( + "https://www.instagram.com/p/CrL74G6Bxgw/?utm_source=ig_web_copy_link".match( + match + ) + ).toBeTruthy(); + }); + + test("to be enabled on reel link", () => { + expect( + "https://www.instagram.com/reel/Cxdyt_fMnwN/?utm_source=ig_web_copy_link".match( + match + ) + ).toBeTruthy(); + }); + + test("to not be enabled elsewhere", () => { + expect("https://www.instagram.com".match(match)).toBe(null); + expect("https://www.instagram.com/reel/".match(match)).toBe(null); + expect("https://www.instagram.com/p/".match(match)).toBe(null); + }); +}); diff --git a/shared/editor/embeds/Instagram.tsx b/shared/editor/embeds/Instagram.tsx new file mode 100644 index 000000000000..5c18401bc7e1 --- /dev/null +++ b/shared/editor/embeds/Instagram.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; +import Frame from "../components/Frame"; +import { EmbedProps as Props } from "."; + +function Instagram(props: Props) { + const { matches } = props.attrs; + return ; +} + +Instagram.ENABLED = [ + /^https?:\/\/www\.instagram\.com\/(p|reel)\/([\w-]+)(\/?utm_source=\w+)?/, +]; + +export default Instagram; diff --git a/shared/editor/embeds/index.tsx b/shared/editor/embeds/index.tsx index 6353111d0a66..96bc2d3ce413 100644 --- a/shared/editor/embeds/index.tsx +++ b/shared/editor/embeds/index.tsx @@ -32,6 +32,7 @@ import GoogleSheets from "./GoogleSheets"; import GoogleSlides from "./GoogleSlides"; import Grist from "./Grist"; import InVision from "./InVision"; +import Instagram from "./Instagram"; import JSFiddle from "./JSFiddle"; import Linkedin from "./Linkedin"; import Loom from "./Loom"; @@ -285,6 +286,12 @@ const embeds: EmbedDescriptor[] = [ icon: Grist, component: Grist, }), + new EmbedDescriptor({ + title: "Instagram", + keywords: "post", + icon: Instagram, + component: Instagram, + }), new EmbedDescriptor({ title: "InVision", keywords: "design prototype", From fb56b00e8137c4451cdefd3e951ed5f3d979b348 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Oct 2023 17:35:35 -0700 Subject: [PATCH 002/241] chore: Auto Compress Images (#6080) Co-authored-by: tommoor --- public/images/instagram.png | Bin 14134 -> 7301 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/images/instagram.png b/public/images/instagram.png index 9d8e3119f6a4a1f762bab7df57eaa8f0117207c3..3ee28e7b243d117d75997ec80914551f625f0948 100644 GIT binary patch literal 7301 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 literal 14134 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≈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 A%m4rY From a6f8872baa3f60ea6ccdc79ccf206bed88db35d1 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 26 Oct 2023 20:07:01 -0400 Subject: [PATCH 003/241] fix: Selecting flag inserts a different flag closes #6079 --- shared/editor/lib/emoji.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/editor/lib/emoji.ts b/shared/editor/lib/emoji.ts index 3dc0addb9c30..acdfca291635 100644 --- a/shared/editor/lib/emoji.ts +++ b/shared/editor/lib/emoji.ts @@ -11,7 +11,7 @@ export const emojiMartToGemoji = { * @param str The string to convert * @returns The converted string */ -export const snakeCase = (str: string) => str.replace(/(\w)(-)(\w)/g, "$1_$2"); +export const snakeCase = (str: string) => str.replace(/(\w)-(\w)/g, "$1_$2"); /** * A map of emoji shortcode to emoji character. The shortcode is snake cased From 33576b794a8280e29cf901a5e2b3170477547cb8 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 26 Oct 2023 20:14:39 -0400 Subject: [PATCH 004/241] fix: Misalignment of menu item icon when text overflows --- app/components/ContextMenu/MenuIconWrapper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/ContextMenu/MenuIconWrapper.ts b/app/components/ContextMenu/MenuIconWrapper.ts index 385e8291dabd..ab0855b7ab1a 100644 --- a/app/components/ContextMenu/MenuIconWrapper.ts +++ b/app/components/ContextMenu/MenuIconWrapper.ts @@ -7,6 +7,7 @@ const MenuIconWrapper = styled.span` margin-right: 6px; margin-left: -4px; color: ${s("textSecondary")}; + flex-shrink: 0; `; export default MenuIconWrapper; From 60941dc285e4b0c5350f7fb317b319339c2f720a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 26 Oct 2023 21:27:06 -0400 Subject: [PATCH 005/241] fix: Title is duplicated on imported collections --- server/commands/documentImporter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/commands/documentImporter.ts b/server/commands/documentImporter.ts index 46ba48b46118..53554e6f9af5 100644 --- a/server/commands/documentImporter.ts +++ b/server/commands/documentImporter.ts @@ -179,7 +179,6 @@ async function documentImporter({ let title = fileName.replace(/\.[^/.]+$/, ""); let text = await fileInfo.getMarkdown(content); - text = text.trim(); // find and extract emoji near the beginning of the document. const regex = emojiRegex(); @@ -191,17 +190,18 @@ async function documentImporter({ // If the first line of the imported text looks like a markdown heading // then we can use this as the document title rather than the file name. - if (text.startsWith("# ")) { + if (text.trim().startsWith("# ")) { const result = parseTitle(text); title = result.title; text = text + .trim() .replace(new RegExp(`#\\s+${escapeRegExp(title)}`), "") .trimStart(); } // Replace any
generated by the turndown plugin with escaped newlines // to match our hardbreak parser. - text = text.replace(/
/gi, "\\n"); + text = text.trim().replace(/
/gi, "\\n"); text = await DocumentHelper.replaceImagesWithAttachments( text, From f23a7bd6859be4671d7153cb661394b771666405 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 26 Oct 2023 21:50:57 -0400 Subject: [PATCH 006/241] fix: Development cannot start s3 --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 88666289827d..1b9fc94e2eb3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ up: - docker-compose up -d redis postgres s3 + docker-compose up -d redis postgres yarn install-local-ssl yarn install --pure-lockfile yarn dev:watch @@ -8,14 +8,14 @@ build: docker-compose build --pull outline test: - docker-compose up -d redis postgres s3 + docker-compose up -d redis postgres yarn sequelize db:drop --env=test yarn sequelize db:create --env=test NODE_ENV=test yarn sequelize db:migrate --env=test yarn test watch: - docker-compose up -d redis postgres s3 + docker-compose up -d redis postgres yarn sequelize db:drop --env=test yarn sequelize db:create --env=test NODE_ENV=test yarn sequelize db:migrate --env=test From b53c595e1bcdeab84c0dab85ca24a48e8749686b Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 26 Oct 2023 21:57:48 -0400 Subject: [PATCH 007/241] fix: FILE_STORAGE_UPLOAD_MAX_SIZE not considered for direct uploads. closes #6078 --- server/routes/api/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index edca8ba81818..13880c119011 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -44,6 +44,7 @@ api.use( bodyParser({ multipart: true, formidable: { + maxFileSize: env.FILE_STORAGE_UPLOAD_MAX_SIZE, maxFieldsSize: 10 * 1024 * 1024, }, }) From 08d89fb57ac07d30706cf9a2f6a71e4f099cac69 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 26 Oct 2023 23:58:02 -0400 Subject: [PATCH 008/241] fix: Enforce emoji flags on macOS --- app/editor/components/EmojiMenu.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx index efd24aa75f3b..5c88588b692d 100644 --- a/app/editor/components/EmojiMenu.tsx +++ b/app/editor/components/EmojiMenu.tsx @@ -5,6 +5,7 @@ import capitalize from "lodash/capitalize"; import sortBy from "lodash/sortBy"; import React from "react"; import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji"; +import { isMac } from "@shared/utils/browser"; import EmojiMenuItem from "./EmojiMenuItem"; import SuggestionsMenu, { Props as SuggestionsMenuProps, @@ -18,7 +19,11 @@ type Emoji = { attrs: { markup: string; "data-name": string }; }; -void init({ data }); +init({ + data, + noCountryFlags: isMac() ? false : undefined, +}); + let searcher: FuzzySearch; type Props = Omit< From 7380f6d5aee94b1cf56a026609080a414904a23a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 10:32:58 -0400 Subject: [PATCH 009/241] fix: Maximum number of connections reached, closes #5446 The opposite of onDisconnect is connected, not onConnect. smh. --- server/collaboration/ConnectionLimitExtension.ts | 8 ++++---- server/collaboration/LoggerExtension.ts | 10 +++++++++- server/collaboration/MetricsExtension.ts | 4 ++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/server/collaboration/ConnectionLimitExtension.ts b/server/collaboration/ConnectionLimitExtension.ts index 3bb4e401739f..3077009d97f3 100644 --- a/server/collaboration/ConnectionLimitExtension.ts +++ b/server/collaboration/ConnectionLimitExtension.ts @@ -1,6 +1,6 @@ import { Extension, - onConnectPayload, + connectedPayload, onDisconnectPayload, } from "@hocuspocus/server"; import env from "@server/env"; @@ -41,10 +41,10 @@ export class ConnectionLimitExtension implements Extension { } /** - * onConnect hook - * @param data The connect payload + * connected hook + * @param data The connected payload */ - onConnect({ documentName, socketId }: withContext) { + connected({ documentName, socketId }: withContext) { const connections = this.connectionsByDocument.get(documentName) || new Set(); if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) { diff --git a/server/collaboration/LoggerExtension.ts b/server/collaboration/LoggerExtension.ts index 473f2a46472c..d6540267c028 100644 --- a/server/collaboration/LoggerExtension.ts +++ b/server/collaboration/LoggerExtension.ts @@ -1,8 +1,9 @@ import { - onConnectPayload, onDisconnectPayload, onLoadDocumentPayload, Extension, + connectedPayload, + onConnectPayload, } from "@hocuspocus/server"; import Logger from "@server/logging/Logger"; import { withContext } from "./types"; @@ -18,6 +19,13 @@ export default class LoggerExtension implements Extension { Logger.info("multiplayer", `New connection to "${data.documentName}"`); } + async connected(data: withContext) { + Logger.info( + "multiplayer", + `Authenticated connection to "${data.documentName}"` + ); + } + async onDisconnect(data: withContext) { Logger.info("multiplayer", `Closed connection to "${data.documentName}"`, { userId: data.context.user?.id, diff --git a/server/collaboration/MetricsExtension.ts b/server/collaboration/MetricsExtension.ts index b5ee6c26cc36..1b73749f8f5a 100644 --- a/server/collaboration/MetricsExtension.ts +++ b/server/collaboration/MetricsExtension.ts @@ -1,9 +1,9 @@ import { onChangePayload, - onConnectPayload, onDisconnectPayload, onLoadDocumentPayload, Extension, + connectedPayload, } from "@hocuspocus/server"; import Metrics from "@server/logging/Metrics"; import { withContext } from "./types"; @@ -28,7 +28,7 @@ export default class MetricsExtension implements Extension { }); } - async onConnect({ documentName, instance }: withContext) { + async connected({ documentName, instance }: withContext) { Metrics.increment("collaboration.connect", { documentName, }); From 057d8a7f3b78bc1fc459fab85dd17812fc666ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agn=C3=A8s=20Haasser?= <1035145+tut-tuuut@users.noreply.github.com> Date: Sat, 28 Oct 2023 17:36:16 +0200 Subject: [PATCH 010/241] API - allow search of a group using its name (#6066) --- server/routes/api/groups/groups.test.ts | 33 +++++++++++++++++++++++++ server/routes/api/groups/groups.ts | 21 ++++++++++++---- server/routes/api/groups/schema.ts | 3 +++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/server/routes/api/groups/groups.test.ts b/server/routes/api/groups/groups.test.ts index 1a6147b31347..1e396172a4e8 100644 --- a/server/routes/api/groups/groups.test.ts +++ b/server/routes/api/groups/groups.test.ts @@ -258,6 +258,39 @@ describe("#groups.list", () => { .includes(anotherUser.id) ).toBe(true); }); + + it("should allow to find a group by its name", async () => { + const user = await buildUser(); + const group = await buildGroup({ + teamId: user.teamId, + }); + const anotherGroup = await buildGroup({ + teamId: user.teamId, + }); + + const unfilteredRes = await server.post("/api/groups.list", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await unfilteredRes.json(); + + expect(unfilteredRes.status).toEqual(200); + expect(body.data.groups.length).toEqual(2); + expect(body.data.groups[0].id).toEqual(anotherGroup.id); + expect(body.data.groups[1].id).toEqual(group.id); + + const anotherRes = await server.post("/api/groups.list", { + body: { + name: group.name, + token: user.getJwtToken(), + }, + }); + const anotherBody = await anotherRes.json(); + expect(anotherRes.status).toEqual(200); + expect(anotherBody.data.groups.length).toEqual(1); + expect(anotherBody.data.groups[0].id).toEqual(group.id); + }); }); describe("#groups.info", () => { diff --git a/server/routes/api/groups/groups.ts b/server/routes/api/groups/groups.ts index a2114d85f755..8fb3a621a9a2 100644 --- a/server/routes/api/groups/groups.ts +++ b/server/routes/api/groups/groups.ts @@ -1,5 +1,5 @@ import Router from "koa-router"; -import { Op } from "sequelize"; +import { Op, WhereOptions } from "sequelize"; import { MAX_AVATAR_DISPLAY } from "@shared/constants"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; @@ -25,13 +25,24 @@ router.post( pagination(), validate(T.GroupsListSchema), async (ctx: APIContext) => { - const { direction, sort, userId } = ctx.input.body; + const { direction, sort, userId, name } = ctx.input.body; const { user } = ctx.state.auth; + let where: WhereOptions = { + teamId: user.teamId, + }; + + if (name) { + where = { + ...where, + name: { + [Op.eq]: name, + }, + }; + } + const groups = await Group.filterByMember(userId).findAll({ - where: { - teamId: user.teamId, - }, + where, order: [[sort, direction]], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, diff --git a/server/routes/api/groups/schema.ts b/server/routes/api/groups/schema.ts index cfa65cda01e5..0fffce3d91a1 100644 --- a/server/routes/api/groups/schema.ts +++ b/server/routes/api/groups/schema.ts @@ -24,6 +24,9 @@ export const GroupsListSchema = z.object({ /** Only list groups where this user is a member */ userId: z.string().uuid().optional(), + + /** Find group with matching name */ + name: z.string().optional(), }), }); From 56f9755cd9089af27e7aa713fa3eca8db48f24d0 Mon Sep 17 00:00:00 2001 From: Translate-O-Tron <75237327+outline-translations@users.noreply.github.com> Date: Sat, 28 Oct 2023 17:38:28 +0200 Subject: [PATCH 011/241] New Crowdin updates (#6036) --- shared/i18n/locales/cs_CZ/translation.json | 3 + shared/i18n/locales/da_DK/translation.json | 3 + shared/i18n/locales/de_DE/translation.json | 3 + shared/i18n/locales/es_ES/translation.json | 13 ++-- shared/i18n/locales/fa_IR/translation.json | 3 + shared/i18n/locales/fr_FR/translation.json | 89 +++++++++++----------- shared/i18n/locales/he_IL/translation.json | 3 + shared/i18n/locales/hu_HU/translation.json | 3 + shared/i18n/locales/id_ID/translation.json | 3 + shared/i18n/locales/it_IT/translation.json | 3 + shared/i18n/locales/ja_JP/translation.json | 3 + shared/i18n/locales/ko_KR/translation.json | 3 + shared/i18n/locales/nl_NL/translation.json | 47 ++++++------ shared/i18n/locales/pl_PL/translation.json | 3 + shared/i18n/locales/pt_BR/translation.json | 3 + shared/i18n/locales/pt_PT/translation.json | 3 + shared/i18n/locales/sv_SE/translation.json | 3 + shared/i18n/locales/th_TH/translation.json | 3 + shared/i18n/locales/tr_TR/translation.json | 3 + shared/i18n/locales/uk_UA/translation.json | 3 + shared/i18n/locales/vi_VN/translation.json | 3 + shared/i18n/locales/zh_CN/translation.json | 7 +- shared/i18n/locales/zh_TW/translation.json | 3 + 23 files changed, 141 insertions(+), 72 deletions(-) diff --git a/shared/i18n/locales/cs_CZ/translation.json b/shared/i18n/locales/cs_CZ/translation.json index 8480d7073541..c32ccd2e8874 100644 --- a/shared/i18n/locales/cs_CZ/translation.json +++ b/shared/i18n/locales/cs_CZ/translation.json @@ -132,6 +132,7 @@ "Submenu": "Podmenu", "Collections could not be loaded, please reload the app": "Sbírky se nepodařilo načíst, prosím načtěte aplikaci znovu", "Default collection": "Výchozí sbírka", + "Install now": "Install now", "Deleted Collection": "Odstraněná sbírka", "Unpin": "Zrušit připnutí", "Search collections & documents": "Prohledat sbírky a dokumenty", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} zrušil publikování", "{{userName}} moved": "{{userName}} přesunul", "Export started": "Export byl zahájen", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Soubor ZIP obsahující obrázky a dokumenty ve formátu Markdown.", "A ZIP file containing the images, and documents as HTML files.": "Soubor ZIP obsahující obrázky a dokumenty ve formátu HTML.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Strukturovaná data, která lze použít k přenosu dat do kompatibilní aplikace {{ appName }}.", diff --git a/shared/i18n/locales/da_DK/translation.json b/shared/i18n/locales/da_DK/translation.json index 1c4412f30ac5..a4f1091b01e2 100644 --- a/shared/i18n/locales/da_DK/translation.json +++ b/shared/i18n/locales/da_DK/translation.json @@ -132,6 +132,7 @@ "Submenu": "Submenu", "Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app", "Default collection": "Default collection", + "Install now": "Install now", "Deleted Collection": "Deleted Collection", "Unpin": "Unpin", "Search collections & documents": "Search collections & documents", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} unpublished", "{{userName}} moved": "{{userName}} moved", "Export started": "Export started", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "A ZIP file containing the images, and documents in the Markdown format.", "A ZIP file containing the images, and documents as HTML files.": "A ZIP file containing the images, and documents as HTML files.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Structured data that can be used to transfer data to another compatible {{ appName }} instance.", diff --git a/shared/i18n/locales/de_DE/translation.json b/shared/i18n/locales/de_DE/translation.json index ff7c44573133..96c139ad2663 100644 --- a/shared/i18n/locales/de_DE/translation.json +++ b/shared/i18n/locales/de_DE/translation.json @@ -132,6 +132,7 @@ "Submenu": "Untermenü", "Collections could not be loaded, please reload the app": "Sammlungen konnten nicht geladen werden, bitte lade die App neu", "Default collection": "Standardsammlung", + "Install now": "Install now", "Deleted Collection": "Gelöschte Sammlung", "Unpin": "Lospinnen", "Search collections & documents": "Sammlungen und Dokumente durchsuchen", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} nicht veröffentlicht", "{{userName}} moved": "{{userName}} verschob", "Export started": "Export gestartet", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Eine ZIP-Datei, die die Bilder und Dokumente im Markdown Format enthält.", "A ZIP file containing the images, and documents as HTML files.": "Eine ZIP-Datei, die die Bilder und Dokumente im HTML Format enthält.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Strukturierte Daten, die verwendet werden können, um Daten an eine andere kompatible {{ appName }} Instanz zu übertragen.", diff --git a/shared/i18n/locales/es_ES/translation.json b/shared/i18n/locales/es_ES/translation.json index 48c3a70813bc..83c8d437290f 100644 --- a/shared/i18n/locales/es_ES/translation.json +++ b/shared/i18n/locales/es_ES/translation.json @@ -104,7 +104,7 @@ "Recent searches": "Búsquedas recientes", "currently editing": "editando actualmente", "currently viewing": "visualizando actualmente", - "previously edited": "editado previamente", + "previously edited": "ha editado previamente", "You": "Tú", "Viewers": "Lectores", "Collection deleted": "Colección eliminada", @@ -132,6 +132,7 @@ "Submenu": "Submenú", "Collections could not be loaded, please reload the app": "No se pudieron cargar las colecciones, por favor recarga la aplicación", "Default collection": "Colección predeterminada", + "Install now": "Install now", "Deleted Collection": "Colección Eliminada", "Unpin": "Desfijar", "Search collections & documents": "Buscar en colecciones y documentos", @@ -148,7 +149,7 @@ "You archived": "Has archivado", "{{ userName }} archived": "{{ userName }} ha archivado", "You created": "Has creado", - "{{ userName }} created": "{{ userName }} ha creado", + "{{ userName }} created": "{{ userName }} creó", "You published": "Has publicado", "{{ userName }} published": "{{ userName }} ha publicado", "You saved": "Has guardado", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} ha despublicado", "{{userName}} moved": "{{userName}} ha movido", "Export started": "Exportación iniciada", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Un archivo ZIP que contiene las imágenes y los documentos en formato Markdown.", "A ZIP file containing the images, and documents as HTML files.": "Un archivo ZIP que contiene las imágenes y los documentos como archivos HTML.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Datos estructurados que se pueden usar para migrar a otra instancia {{ appName }} compatible.", @@ -368,8 +371,8 @@ "Group member options": "Opciones de los miembros del grupo", "Export collection": "Exportar colección", "Sort in sidebar": "Ordenar en barra lateral", - "Alphabetical sort": "Ordenación alfabética", - "Manual sort": "Ordenación manual", + "Alphabetical sort": "Orden alfabético", + "Manual sort": "Orden manual", "Delete comment": "Eliminar comentario", "Comment options": "Opciones de los comentarios", "Document restored": "Documento restaurado", @@ -533,7 +536,7 @@ "Last updated": "Última actualización", "Creator": "Creador", "Last edited": "Última edición", - "Previously edited": "Editado previamente", + "Previously edited": "Ha editado previamente", "Views": "Vistas", "No one else has viewed yet": "Nadie más lo ha visto aún", "Viewed {{ count }} times by {{ teamMembers }} people": "Visto {{ count }} vez por {{ teamMembers }} personas", diff --git a/shared/i18n/locales/fa_IR/translation.json b/shared/i18n/locales/fa_IR/translation.json index 8677493b88d2..0c78f237ed1e 100644 --- a/shared/i18n/locales/fa_IR/translation.json +++ b/shared/i18n/locales/fa_IR/translation.json @@ -132,6 +132,7 @@ "Submenu": "زیرمنو", "Collections could not be loaded, please reload the app": "مجموعه‌ها بارگذاری نمی‌شوند، لطفاً برنامه را دوباره بارگیری کنید", "Default collection": "مجموعه پیش فرض", + "Install now": "Install now", "Deleted Collection": "مجموعه‌های حذف شده", "Unpin": "برداشتن سنجاق", "Search collections & documents": "جستجوی مجموعه ها و اسناد", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} unpublished", "{{userName}} moved": "{{userName}} منتقل شد", "Export started": "Export started", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "A ZIP file containing the images, and documents in the Markdown format.", "A ZIP file containing the images, and documents as HTML files.": "A ZIP file containing the images, and documents as HTML files.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Structured data that can be used to transfer data to another compatible {{ appName }} instance.", diff --git a/shared/i18n/locales/fr_FR/translation.json b/shared/i18n/locales/fr_FR/translation.json index 6ee3c50bc73b..6235813d901f 100644 --- a/shared/i18n/locales/fr_FR/translation.json +++ b/shared/i18n/locales/fr_FR/translation.json @@ -21,10 +21,10 @@ "New from template": "Nouveau à partir d'un modèle", "New nested document": "Nouveau document imbriqué", "Publish": "Publier", - "Published {{ documentName }}": "Published {{ documentName }}", + "Published {{ documentName }}": "Publication de {{ documentName }}", "Publish document": "Publier le document", "Unpublish": "Dépublier", - "Unpublished {{ documentName }}": "Unpublished {{ documentName }}", + "Unpublished {{ documentName }}": "Dépublication de {{ documentName }}", "Subscribe": "S'abonner", "Subscribed to document notifications": "Abonné aux notifications du document", "Unsubscribe": "Se désabonner", @@ -37,12 +37,12 @@ "Download document": "Télécharger le document", "Duplicate": "Dupliquer", "Duplicate document": "Dupliquer le document", - "Copy document": "Copy document", + "Copy document": "Copier document", "collection": "collection", "Pin to {{collectionName}}": "Épingler à {{collectionName}}", "Pinned to collection": "Épinglé à la collection", "Pin to home": "Épingler sur la page d'accueil", - "Pinned to home": "Pinned to home", + "Pinned to home": "Épinglé à l'écran d'accueil", "Pin": "Épingler", "Print": "Imprimer", "Print document": "Imprimer le document", @@ -132,6 +132,7 @@ "Submenu": "Sous-menu", "Collections could not be loaded, please reload the app": "Les collections n'ont pas pu être chargées, veuillez recharger la page", "Default collection": "Collection par défaut", + "Install now": "Installer maintenant", "Deleted Collection": "Collection supprimée", "Unpin": "Désépingler", "Search collections & documents": "Rechercher dans les collections et documents", @@ -153,7 +154,7 @@ "{{ userName }} published": "{{ userName }} a publié", "You saved": "Vous avez enregistré", "{{ userName }} saved": "{{ userName }} a enregistré", - "Never viewed": "Jamais vu", + "Never viewed": "Jamais consulté", "Viewed": "Vu", "in": "dans", "nested document": "document imbriqué", @@ -169,10 +170,10 @@ "Currently editing": "En cours de modification", "Currently viewing": "En train de consulter", "Viewed {{ timeAgo }}": "Vu il y a {{ timeAgo }}", - "Copy of {{ documentName }}": "Copy of {{ documentName }}", - "Title": "Title", - "Include nested documents": "Include nested documents", - "Emoji Picker": "Emoji Picker", + "Copy of {{ documentName }}": "Copie de {{ documentName }}", + "Title": "Titre", + "Include nested documents": "Inclure les documents imbriqués", + "Emoji Picker": "Sélecteur d'émojis", "Remove": "Supprimer", "Module failed to load": "Le module n'a pas pu être chargé", "Loading Failed": "Échec du chargement", @@ -181,7 +182,7 @@ "Something Unexpected Happened": "Quelque chose d'inattendu s'est produit", "Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Désolé, une erreur irrécupérable s'est produite{{notified}}. Essayez d'actualiser la page, il s'agit peut-être d'un problème temporaire.", "our engineers have been notified": "nos ingénieurs ont été notifiés", - "Show detail": "Show detail", + "Show detail": "Afficher détail", "Current version": "Version actuelle", "{{userName}} edited": "{{userName}} a modifié", "{{userName}} archived": "{{userName}} a archivé", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} a annulé la publication", "{{userName}} moved": "{{userName}} a déplacé", "Export started": "Export démarré", + "Your file will be available in {{ location }} soon": "Votre fichier sera bientôt disponible sur {{ location }}", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Un fichier ZIP contenant les images et les documents au format Markdown.", "A ZIP file containing the images, and documents as HTML files.": "Un fichier ZIP contenant les images et les documents sous forme de fichiers HTML.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Les données structurées peuvent être utilisées pour transmettre des données vers une autre instance {{ appName }} compatible.", @@ -247,8 +250,8 @@ "{{ releasesBehind }} versions behind_plural": "{{ releasesBehind }} versions de retard", "Return to App": "Retour à l’app", "Installation": "Installation", - "Unstar document": "Unstar document", - "Star document": "Star document", + "Unstar document": "Retirer le document des favoris", + "Star document": "Mettre en favoris le document", "No results": "Aucun résultat", "Previous page": "Page précédente", "Next page": "Page suivante", @@ -345,9 +348,9 @@ "Current date and time": "Date et heure actuelle", "Indent": "Indenter", "Outdent": "Désindenter", - "Video": "Video", + "Video": "Vidéo", "Could not import file": "Le fichier n'a pas pu être importé", - "Unsubscribed from document": "Unsubscribed from document", + "Unsubscribed from document": "Se désabonner du document", "Account": "Compte", "API Tokens": "Jetons API", "Details": "Détails", @@ -360,7 +363,7 @@ "Self Hosted": "Auto-hébergées", "Integrations": "Intégrations", "Google Analytics": "Google Analytics", - "Choose a template": "Choose a template", + "Choose a template": "Choisir un modèle", "Revoke token": "Supprimer le jeton", "Revoke": "Supprimer", "Show path to document": "Afficher le chemin d'accès au document", @@ -376,7 +379,7 @@ "Document options": "Options de document", "Restore": "Restaurer", "Choose a collection": "Choisir une collection", - "Rename": "Rename", + "Rename": "Renommer", "Enable embeds": "Activer les intégrations", "Export options": "Options d'exportation", "Edit group": "Modifier le groupe", @@ -406,7 +409,7 @@ "Resend invite": "Renvoyer l'invitation", "Revoke invite": "Révoquer l'invitation", "Activate account": "Activer le compte", - "template": "template", + "template": "modèle", "document": "document", "published": "publié", "edited": "modifié", @@ -456,7 +459,7 @@ "No groups left to add": "Plus aucun groupe à ajouter", "Add": "Ajouter", "{{ userName }} was added to the collection": "{{ userName }} été ajouté à la collection", - "Need to add someone who’s not on the team yet?": "Need to add someone who’s not on the team yet?", + "Need to add someone who’s not on the team yet?": "Besoin d'ajouter quelqu'un qui ne fait pas encore partie de l'équipe ?", "Invite people to {{ teamName }}": "Inviter des personnes à {{ teamName }}", "Search by name": "Rechercher par nom", "Search people": "Rechercher des personnes", @@ -480,7 +483,7 @@ "Workspace members can view and edit documents in the {{ collectionName }} collection by default.": "Les membres de l'espace de travail peuvent afficher les documents de la collection {{ collectionName }} par défaut.", "Workspace members can view documents in the {{ collectionName }} collection by\n default.": "Les membres de l'espace de travail peuvent afficher les documents de la collection {{ collectionName }} par défaut.", "When enabled, documents can be shared publicly on the internet.": "Lorsque cette option est activée, les documents peuvent être partagés publiquement sur Internet.", - "Public sharing is currently disabled in the workspace security settings.": "Public sharing is currently disabled in the workspace security settings.", + "Public sharing is currently disabled in the workspace security settings.": "Le partage public est actuellement désactivé dans les paramètres de sécurité de l'espace de travail.", "Additional access": "Accès supplémentaire", "Add groups": "Ajouter des groupes", "Add people": "Ajouter quelqu'un", @@ -511,8 +514,8 @@ "Switch to dark": "Passer en mode sombre", "Switch to light": "Passer en mode clair", "Archived": "Archivé", - "Save draft": "Save draft", - "Done editing": "Done editing", + "Save draft": "Enregistrer le brouillon", + "Done editing": "Édition terminée", "Restore version": "Restaurer cette version", "No history yet": "Pas encore d'historique", "Stats": "Statistiques", @@ -601,7 +604,7 @@ "We were unable to load the document while offline.": "Impossible de charger le document en mode hors-ligne.", "Your account has been suspended": "Votre compte a été suspendu", "Warning Sign": "Signe d'avertissement", - "A workspace admin ({{ suspendedContactEmail }}) has suspended your account. To re-activate your account, please reach out to them directly.": "A workspace admin ({{ suspendedContactEmail }}) has suspended your account. To re-activate your account, please reach out to them directly.", + "A workspace admin ({{ suspendedContactEmail }}) has suspended your account. To re-activate your account, please reach out to them directly.": "Un administrateur de l'espace de travail ({{ suspendedContactEmail }}) a suspendu votre compte. Pour réactiver votre compte, veuillez les contacter directement.", "Are you sure about that? Deleting the {{groupName}} group will cause its members to lose access to collections and documents that it is associated with.": "Êtes-vous sûr de vouloir faire ça ? La suppression du groupe {{groupName}} fera perdre à ses membres l'accès aux collections et aux documents auxquels il est associé.", "You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "Vous pouvez modifier le nom de ce groupe à tout moment, mais le faire trop souvent risquerait de perturber les membres de votre équipe.", "{{userName}} was added to the group": "{{userName}} été ajouté au groupe", @@ -671,7 +674,7 @@ "The domain associated with your email address has not been allowed for this workspace.": "Le domaine associé à votre adresse e-mail n'a pas été autorisé pour cet espace de travail.", "Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1>If you were invited to a workspace, you will find a link to it in the invite email.": "Impossible de se connecter. Veuillez accéder à l'URL personnalisée de votre espace de travail, puis réessayez de vous connecter.<1> Si vous avez été invité à rejoindre un espace de travail, vous trouverez un lien vers celui-ci dans l'e-mail d'invitation.", "Sorry, a new account cannot be created with a personal Gmail address.<1>Please use a Google Workspaces account instead.": "Désolé, un nouveau compte ne peut pas être créé avec une adresse Gmail personnelle.<1> Veuillez plutôt utiliser un compte Google Workspaces.", - "The workspace associated with your user is scheduled for deletion and cannot at accessed at this time.": "The workspace associated with your user is scheduled for deletion and cannot at accessed at this time.", + "The workspace associated with your user is scheduled for deletion and cannot at accessed at this time.": "L'espace de travail associé avec votre nom d'utilisateur est programmé pour suppression et ne peut actuellement pas accessible.", "The workspace you authenticated with is not authorized on this installation. Try another?": "L'espace de travail avec lequel vous vous êtes authentifié n'est pas autorisé sur cette installation. Essayez un autre ?", "We could not read the user info supplied by your identity provider.": "Nous n'avons pas pu lire les informations utilisateur fournies par votre fournisseur d'identité.", "Your account uses email sign-in, please sign-in with email to continue.": "Votre compte utilise une connexion par e-mail, veuillez vous connecter avec une adresse e-mail pour continuer.", @@ -739,11 +742,11 @@ "Completed": "Terminé", "Failed": "Échoué", "All collections": "Toutes les collections", - "Import deleted": "Import deleted", + "Import deleted": "Importation supprimée", "Export deleted": "Exportation supprimée", - "Are you sure you want to delete this import?": "Are you sure you want to delete this import?", + "Are you sure you want to delete this import?": "Voulez-vous vraiment supprimer cette importation ?", "I’m sure": "Je suis sûr", - "Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.": "Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.", + "Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.": "La suppression de cette importation supprimera également toutes les collections et tous les documents qui ont été créés à partir de celle-ci. Cette opération est irréversible.", "{{userName}} requested": "{{userName}} avez demandé un export", "Upload": "Envoyer", "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Glissez et déposez le fichier zip provenant d'un export JSON de {{appName}}, ou cliquez pour le téléverser", @@ -766,8 +769,8 @@ "Settings saved": "Paramètres enregistrés", "Logo updated": "Logo mis à jour", "Unable to upload new logo": "Impossible de charger le logo", - "Delete workspace": "Delete workspace", - "These settings affect the way that your workspace appears to everyone on the team.": "These settings affect the way that your workspace appears to everyone on the team.", + "Delete workspace": "Supprimer l'espace de travail", + "These settings affect the way that your workspace appears to everyone on the team.": "Ces paramètres changent la façon dont l'espace de travail apparait pour tous les membres de l'équipe.", "Display": "Affichage", "The logo is displayed at the top left of the application.": "Le logo apparaît en haut à gauche de l'application.", "The workspace name, usually the same as your company name.": "Le nom de l'espace de travail, généralement le même que celui de votre entreprise.", @@ -780,18 +783,18 @@ "Show your team’s logo on public pages like login and shared documents.": "Affichez le logo de votre équipe sur les pages publiques telles que l'authentification et les documents partagés.", "Behavior": "Comportement", "Subdomain": "Sous-domaine", - "Your workspace will be accessible at": "Your workspace will be accessible at", + "Your workspace will be accessible at": "Votre espace de travail sera accessible à", "Choose a subdomain to enable a login page just for your team.": "Choisissez un sous-domaine pour activer une page de connexion propre à votre équipe.", "Start view": "Page d'accueil", "This is the screen that workspace members will first see when they sign in.": "Cette page s'affichera pour les membres de l'espace de travail lors de leur première connexion.", "Danger": "Danger", - "You can delete this entire workspace including collections, documents, and users.": "You can delete this entire workspace including collections, documents, and users.", + "You can delete this entire workspace including collections, documents, and users.": "Vous pouvez supprimer tout l'espace de travail dont les collections, les documents et les utilisateurs.", "Export data": "Export des données", "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to {{ userEmail }} when it’s complete.": "Un export complet peut prendre un certain temps, pensez à exporter un seul document ou une seule collection. Les données sont exportées sous forme d'archive zip de vos documents en format Markdown. Une fois l'export commencé, ous pouvez fermer cette page – nous enverrons un lien à {{ userEmail }} quand ce sera terminé.", "Recent exports": "Exports récents", "Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.": "Gérer les fonctionnalités optionnelles et bêta. La modification de ces paramètres sera effective pour tous les membres de l'espace de travail.", - "Separate editing": "Separate editing", - "When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences.": "When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences.", + "Separate editing": "Édition séparée", + "When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences.": "Lorsque cette option est activée, les documents ont un mode d'édition séparé au lieu d'être toujours modifiables. Ce paramètre peut être changé les préférences de l'utilisateur.", "Commenting": "Commentaires", "When enabled team members can add comments to documents.": "Lorsque cette option est activée, les membres de l'équipe peuvent ajouter des commentaires aux documents.", "Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.": "Ajouter un identifiant de mesure Google Analytics 4 pour envoyer le nombre de vues de documents et les statistiques de l'espace de travail à votre propre compte Google Analytics.", @@ -856,7 +859,7 @@ "This could be your real name, or a nickname — however you’d like people to refer to you.": "Il peut s'agir de votre vrai nom ou d'un surnom - selon la façon dont vous aimeriez être désigné.", "Are you sure you want to require invites?": "Êtes-vous sûr de vouloir exiger des invitations ?", "New users will first need to be invited to create an account. Default role and Allowed domains will no longer apply.": "Les nouveaux utilisateurs devront être invités pour pouvoir créer un compte. Le Rôle par défaut et les Domaines autorisés ne s'appliqueront plus.", - "Settings that impact the access, security, and content of your workspace.": "Settings that impact the access, security, and content of your workspace.", + "Settings that impact the access, security, and content of your workspace.": "Paramètres agissant sur l'accès, la sécurité et le contenu de votre espace de travail.", "Allow members to sign-in with {{ authProvider }}": "Autoriser les membres à se connecter avec {{ authProvider }}", "Connected": "Connecté", "Disabled": "Désactivé", @@ -873,7 +876,7 @@ "Rich service embeds": "Intégrations avancées", "Links to supported services are shown as rich embeds within your documents": "Les liens vers les services pris en charge sont affichés comme des intégrations avancées", "Collection creation": "Création de collections", - "Allow members to create new collections within the workspace": "Allow members to create new collections within the workspace", + "Allow members to create new collections within the workspace": "Autoriser les membres à créer de nouvelles collections dans l'espace de travail", "Draw.io deployment": "Déploiement de Draw.io", "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Ajoutez ici l'url de votre installation draw.io auto-hébergée pour activer l'intégration automatique des diagrammes dans les documents.", "Grist deployment": "Déploiement Grist", @@ -885,17 +888,17 @@ "Alphabetical": "Alphabétique", "There are no templates just yet.": "Il n'y a pas encore de modèles.", "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier est une plate-forme qui permet à {{appName}} de s'intégrer facilement à des milliers d'autres outils professionnels. Automatisez vos flux de travail, synchronisez les données, et plus.", - "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.", - "Confirmation code": "Confirmation code", - "Deleting the <1>{{workspaceName}} workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.": "Deleting the <1>{{workspaceName}} workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.", - "Please note that workspaces are completely separate. They can have a different domain, settings, users, and billing.": "Please note that workspaces are completely separate. They can have a different domain, settings, users, and billing.", + "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.": "Un code de confirmation a été envoyé à votre adresse e-mail, veuillez saisir le code ci-dessous pour supprimer définitivement cet espace de travail.", + "Confirmation code": "Code de confirmation", + "Deleting the <1>{{workspaceName}} workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.": "Supprimer l'espace de travail <1>{{workspaceName}} va détruire toutes les collections, tous les documents et utilisateurs ainsi que les données associées. Vous serez immédiatement déconnecté de {{appName}}.", + "Please note that workspaces are completely separate. They can have a different domain, settings, users, and billing.": "Veuillez noter que les espaces de travail sont complètement indépendants. Ils peuvent avoir un domaine, des réglages, des utilisateurs et une facturation complètement différents.", "Workspace name": "Nom de l'espace de travail", "Your are creating a new workspace using your current account — {{email}}": "Vous créez un nouvel espace de travail avec votre compte actuel — {{email}}", - "To create a workspace under another email please sign up from the homepage": "To create a workspace under another email please sign up from the homepage", + "To create a workspace under another email please sign up from the homepage": "Pour créer un espace de travail sous une autre adresse e-mail, veuillez vous inscrire sur la page d'accueil", "Trash is empty at the moment.": "La corbeille est vide pour le moment.", - "A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.", + "A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.": "Un code de confirmation a été envoyé à votre adresse e-mail, veuillez saisir le code ci-dessous pour supprimer définitivement votre compte.", "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.": "Êtes-vous sûr ? La suppression de votre compte détruira les données d'identification associées à votre utilisateur et ne pourra pas être annulé. Vous serez immédiatement déconnecté de {{appName}} et tous vos jetons API seront révoqués.", - "Delete my account": "Delete my account", + "Delete my account": "Supprimer mon compte", "Today": "Aujourd’hui", "Yesterday": "Hier", "Last week": "La semaine dernière", @@ -908,14 +911,14 @@ "Posting to the {{ channelName }} channel on": "Envoi sur le canal {{ channelName }}", "These events should be posted to Slack": "Ces événements doivent être publiés sur Slack", "Disconnect": "Se déconnecter", - "Whoops, you need to accept the permissions in Slack to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in Slack to connect {{appName}} to your workspace. Try again?", + "Whoops, you need to accept the permissions in Slack to connect {{appName}} to your workspace. Try again?": "Oups, vous devez accepter les autorisations dans Slack pour connecter {{appName}} à votre espace de travail. Réessayer ?", "Something went wrong while authenticating your request. Please try logging in again.": "Something went wrong while authenticating your request. Please try logging in again.", "Get rich previews of {{ appName }} links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.": "Obtenez un aperçu complets des liens {{ appName }} partagés dans Slack et utilisez la commande {{ command }} pour chercher des documents sans quitter la discussion.", "Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.": "Connectez les collections {{appName}} aux canaux Slack . Les messages seront automatiquement publiés sur Slack lorsque des documents sont publiés ou mis à jour.", "Connect": "Se connecter", "The Slack integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "L'intégration de Slack est actuellement désactivée. Veuillez définir les variables d'environnement associées et redémarrer le serveur pour activer l'intégration.", "How to use {{ command }}": "Comment utiliser {{ command }}", - "To search your workspace use {{ command }}. \nType {{ command2 }} help to display this help text.": "To search your workspace use {{ command }}. \nType {{ command2 }} help to display this help text.", + "To search your workspace use {{ command }}. \nType {{ command2 }} help to display this help text.": "Pour rechercher dans votre espace de travail, utilisez {{ command }}. \nAppuyez sur {{ command2 }} pour afficher ce texte d'aide.", "Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.": "Désolé, nous n'avons pas trouvé d'intégration pour votre équipe. Rendez-vous dans vos paramètres {{ appName }} pour en configurer un.", "It looks like you haven’t signed in to {{ appName }} yet, so results may be limited": "Il semble que vous ne vous soyez pas encore authentifié à {{ appName }} , les résultats peuvent donc être limités", "Post to Channel": "Publier sur le canal", diff --git a/shared/i18n/locales/he_IL/translation.json b/shared/i18n/locales/he_IL/translation.json index 4f7ec8ee6f29..30ffee74961a 100644 --- a/shared/i18n/locales/he_IL/translation.json +++ b/shared/i18n/locales/he_IL/translation.json @@ -132,6 +132,7 @@ "Submenu": "Submenu", "Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app", "Default collection": "Default collection", + "Install now": "Install now", "Deleted Collection": "Deleted Collection", "Unpin": "Unpin", "Search collections & documents": "Search collections & documents", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} unpublished", "{{userName}} moved": "{{userName}} moved", "Export started": "Export started", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "A ZIP file containing the images, and documents in the Markdown format.", "A ZIP file containing the images, and documents as HTML files.": "A ZIP file containing the images, and documents as HTML files.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Structured data that can be used to transfer data to another compatible {{ appName }} instance.", diff --git a/shared/i18n/locales/hu_HU/translation.json b/shared/i18n/locales/hu_HU/translation.json index 1f3d3b6049ad..d48c4d72c936 100644 --- a/shared/i18n/locales/hu_HU/translation.json +++ b/shared/i18n/locales/hu_HU/translation.json @@ -132,6 +132,7 @@ "Submenu": "Almenü", "Collections could not be loaded, please reload the app": "A gyűjtemények nem tölthetők be, kérem töltse újra a programot", "Default collection": "Alapértelmezett gyűjtemény", + "Install now": "Install now", "Deleted Collection": "Törölt gyűjtemény", "Unpin": "Feloldás", "Search collections & documents": "Keresés a gyűteményekben és dokumentumokban", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} visszavonta", "{{userName}} moved": "{{userName}} áthelyezte", "Export started": "Az exportálás elindult", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Egy ZIP állomány, melyben a képek és Markdown formátumú dokumenumok találhatóak.", "A ZIP file containing the images, and documents as HTML files.": "Egy ZIP állomány, melyben a képek és HTML formátumú dokumenumok találhatóak.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Strukturált adat, mely adatátvitelre használható egy másik, kompatibilis {{ appName }} példányba.", diff --git a/shared/i18n/locales/id_ID/translation.json b/shared/i18n/locales/id_ID/translation.json index 1521b19d9f89..b89f3ecfb0b2 100644 --- a/shared/i18n/locales/id_ID/translation.json +++ b/shared/i18n/locales/id_ID/translation.json @@ -132,6 +132,7 @@ "Submenu": "Submenu", "Collections could not be loaded, please reload the app": "Koleksi tidak dapat dimuat, harap muat ulang aplikasi", "Default collection": "Koleksi bawaan", + "Install now": "Install now", "Deleted Collection": "Koleksi Dihapus", "Unpin": "Unpin", "Search collections & documents": "Telusuri koleksi & dokumen", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} tidak menerbitkan", "{{userName}} moved": "{{userName}} memindahkan", "Export started": "Ekspor dimulai", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Berkas ZIP yang berisi gambar, dan dokumen dalam format Markdown.", "A ZIP file containing the images, and documents as HTML files.": "Berkas ZIP yang berisi gambar, dan dokumen sebagai berkas HTML.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Data terstruktur yang dapat digunakan untuk memindahkan data ke server {{ appName }} lain yang kompatibel.", diff --git a/shared/i18n/locales/it_IT/translation.json b/shared/i18n/locales/it_IT/translation.json index 35814b906aeb..146d1a114a53 100644 --- a/shared/i18n/locales/it_IT/translation.json +++ b/shared/i18n/locales/it_IT/translation.json @@ -132,6 +132,7 @@ "Submenu": "Sottomenu", "Collections could not be loaded, please reload the app": "Impossibile caricare le raccolte, per favore ricarica l'app", "Default collection": "Raccolta predefinita", + "Install now": "Install now", "Deleted Collection": "Raccolte Eliminate", "Unpin": "Rimuovi contrassegno", "Search collections & documents": "Cerca raccolte e documenti", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} ha annullato la pubblicazione", "{{userName}} moved": "{{userName}} ha spostato", "Export started": "Esportazione avviata", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Un file ZIP contenente le immagini e i documenti nel formato Markdown.", "A ZIP file containing the images, and documents as HTML files.": "Un file ZIP contenente le immagini e documenti come file HTML.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Dati strutturati che possono essere utilizzati per trasferire i dati a un'altra istanza {{ appName }} compatibile.", diff --git a/shared/i18n/locales/ja_JP/translation.json b/shared/i18n/locales/ja_JP/translation.json index 6ffa911614e8..e903279e9b0e 100644 --- a/shared/i18n/locales/ja_JP/translation.json +++ b/shared/i18n/locales/ja_JP/translation.json @@ -132,6 +132,7 @@ "Submenu": "サブメニュー", "Collections could not be loaded, please reload the app": "コレクションを読み込めませんでした。アプリを再読み込みしてください。", "Default collection": "デフォルトコレクション", + "Install now": "Install now", "Deleted Collection": "削除したコレクション", "Unpin": "固定しない", "Search collections & documents": "ドキュメントやコレクションを検索する", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} はドキュメントを取り下げました", "{{userName}} moved": "{{userName}} が移動しました", "Export started": "エクスポートを開始しました", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "画像、およびMarkdown形式のドキュメントを含むZIPファイル", "A ZIP file containing the images, and documents as HTML files.": "画像、およびHTML形式のドキュメントを含むZIPファイル", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Structured data that can be used to transfer data to another compatible {{ appName }} instance.", diff --git a/shared/i18n/locales/ko_KR/translation.json b/shared/i18n/locales/ko_KR/translation.json index 198ce916c1a1..0c7ebaae6b0b 100644 --- a/shared/i18n/locales/ko_KR/translation.json +++ b/shared/i18n/locales/ko_KR/translation.json @@ -132,6 +132,7 @@ "Submenu": "하위 메뉴", "Collections could not be loaded, please reload the app": "컬렉션을 불러올 수 없습니다. 앱을 새로고침하세요", "Default collection": "기본 컬렉션", + "Install now": "Install now", "Deleted Collection": "삭제 된 콜렉션", "Unpin": "고정 해제", "Search collections & documents": "컬렉션 및 문서 검색", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} 이(가) 게시 취소", "{{userName}} moved": "{{userName}} 이(가) 이동함", "Export started": "내보내기 시작됨", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "마크다운 형식의 이미지 및 문서가 포함된 ZIP 파일.", "A ZIP file containing the images, and documents as HTML files.": "HTML 형식의 이미지 및 문서가 포함된 ZIP 파일.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "호환되는 다른 {{ appName }} 인스턴스로 데이터를 전송하는 데 사용할 수 있는 구조화된 데이터입니다.", diff --git a/shared/i18n/locales/nl_NL/translation.json b/shared/i18n/locales/nl_NL/translation.json index ced3e7f2957c..cf1ca0d26cbe 100644 --- a/shared/i18n/locales/nl_NL/translation.json +++ b/shared/i18n/locales/nl_NL/translation.json @@ -21,7 +21,7 @@ "New from template": "Nieuw op basis van sjabloon", "New nested document": "Nieuw genest document", "Publish": "Publiceer", - "Published {{ documentName }}": "Published {{ documentName }}", + "Published {{ documentName }}": "{{ documentName }} gepubliceerd", "Publish document": "Publiceer document", "Unpublish": "De-publiceer", "Unpublished {{ documentName }}": "Unpublished {{ documentName }}", @@ -37,7 +37,7 @@ "Download document": "Download document", "Duplicate": "Dupliceer", "Duplicate document": "Document dupliceren", - "Copy document": "Copy document", + "Copy document": "Kopieer document", "collection": "collectie", "Pin to {{collectionName}}": "Vastmaken aan {{collectionName}}", "Pinned to collection": "Vastgemaakt aan collectie", @@ -107,7 +107,7 @@ "previously edited": "eerder bewerkt", "You": "Jij", "Viewers": "Kijkers", - "Collection deleted": "Collection deleted", + "Collection deleted": "Collectie verwijderd", "I’m sure – Delete": "Dat weet ik zeker – Verwijder", "Deleting": "Verwijderen", "Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however all published documents within will be moved to the trash.": "Weet je dat zeker? Het verwijderen van de collectie {{collectionName}} is permanent en kan niet worden hersteld. Alle gepubliceerde documenten in deze collectie worden echter verplaatst naar de prullenbak.", @@ -119,7 +119,7 @@ "Type a command or search": "Zoek of typ een opdracht", "Are you sure you want to permanently delete this entire comment thread?": "Weet je zeker dat je deze discussie definitief wilt verwijderen?", "Are you sure you want to permanently delete this comment?": "Weet je zeker dat je deze reactie definitief wilt verwijderen?", - "Document is too large": "Document is too large", + "Document is too large": "Document is te groot", "This document has reached the maximum size and can no longer be edited": "This document has reached the maximum size and can no longer be edited", "Authentication failed": "Authentication failed", "Please try logging out and back in again": "Please try logging out and back in again", @@ -132,6 +132,7 @@ "Submenu": "Submenu", "Collections could not be loaded, please reload the app": "Collecties konden niet worden geladen, start de app alsjeblieft opnieuw op", "Default collection": "Standaard collectie", + "Install now": "Install now", "Deleted Collection": "Verwijderde Collectie", "Unpin": "Losmaken", "Search collections & documents": "Zoeken in collecties en documenten", @@ -170,9 +171,9 @@ "Currently viewing": "Momenteel aan het bekijken", "Viewed {{ timeAgo }}": "{{ timeAgo }} bekeken", "Copy of {{ documentName }}": "Copy of {{ documentName }}", - "Title": "Title", + "Title": "Titel", "Include nested documents": "Include nested documents", - "Emoji Picker": "Emoji Picker", + "Emoji Picker": "Emoji-kiezer", "Remove": "Verwijder", "Module failed to load": "Module kon niet geladen worden", "Loading Failed": "Laden mislukt", @@ -181,7 +182,7 @@ "Something Unexpected Happened": "Er gebeurde iets onverwachts", "Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Sorry, er is een onherstelbare fout opgetreden op{{notified}}. Probeer de pagina opnieuw te laden, dit kan een tijdelijke storing zijn.", "our engineers have been notified": "onze technici zijn op de hoogte gebracht", - "Show detail": "Show detail", + "Show detail": "Toon detail", "Current version": "Huidige versie", "{{userName}} edited": "{{userName}} bewerkt", "{{userName}} archived": "{{userName}} gearchiveerd", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} gedepubliceerd", "{{userName}} moved": "{{userName}} verplaatst", "Export started": "Exporteren is begonnen", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Een Zip-bestand met de afbeeldingen en documenten als Markdown bestanden.", "A ZIP file containing the images, and documents as HTML files.": "Een Zip-bestand met de afbeeldingen en documenten als HTML-bestanden.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Gestructureerde gegevens die kunnen worden gebruikt om gegevens te migreren naar een andere compatibele {{ appName }} installatie.", @@ -263,16 +266,16 @@ "Save": "Bewaren", "New name": "Nieuwe naam", "Name can't be empty": "Naam mag niet leeg zijn", - "Previous match": "Previous match", - "Next match": "Next match", - "Find and replace": "Find and replace", - "Find": "Find", + "Previous match": "Vorige match", + "Next match": "Volgende match", + "Find and replace": "Zoek en vervang", + "Find": "Zoek", "Match case": "Match case", "Enable regex": "Enable regex", "Replace options": "Replace options", "Replacement": "Replacement", - "Replace": "Replace", - "Replace all": "Replace all", + "Replace": "Vervang", + "Replace all": "Vervang alle", "Profile picture": "Profielfoto", "Insert column after": "Voeg een kolom rechts in", "Insert column before": "Voeg een kolom links in", @@ -360,7 +363,7 @@ "Self Hosted": "Self-hosted", "Integrations": "Intergraties", "Google Analytics": "Google Analytics", - "Choose a template": "Choose a template", + "Choose a template": "Kies een sjabloon", "Revoke token": "Tokens intrekken", "Revoke": "Intrekken", "Show path to document": "Toon pad naar document", @@ -376,7 +379,7 @@ "Document options": "Documentopties", "Restore": "Herstel", "Choose a collection": "Kies een collectie", - "Rename": "Rename", + "Rename": "Hernoem", "Enable embeds": "Embeds inschakelen", "Export options": "Exporteer instellingen", "Edit group": "Groep bewerken", @@ -406,7 +409,7 @@ "Resend invite": "Uitnodiging opnieuw verzenden", "Revoke invite": "Uitnodiging intrekken", "Activate account": "Activeer account", - "template": "template", + "template": "sjabloon", "document": "document", "published": "publiceerde", "edited": "gewijzigd", @@ -511,8 +514,8 @@ "Switch to dark": "Wissel naar donker thema", "Switch to light": "Wissel naar licht thema", "Archived": "Gearchiveerd", - "Save draft": "Save draft", - "Done editing": "Done editing", + "Save draft": "Bewaar concept", + "Done editing": "Klaar met bewerken", "Restore version": "Versie herstellen", "No history yet": "Nog geen geschiedenis", "Stats": "Stats", @@ -766,7 +769,7 @@ "Settings saved": "Instellingen opgeslagen", "Logo updated": "Logo bijgewerkt", "Unable to upload new logo": "Kan het nieuwe logo niet uploaden", - "Delete workspace": "Delete workspace", + "Delete workspace": "Verwijder workspace", "These settings affect the way that your workspace appears to everyone on the team.": "These settings affect the way that your workspace appears to everyone on the team.", "Display": "Toon", "The logo is displayed at the top left of the application.": "Het logo wordt weergegeven in de linkerbovenhoek van de applicatie.", @@ -784,7 +787,7 @@ "Choose a subdomain to enable a login page just for your team.": "Kies een subdomein om een inlogpagina alleen voor jouw team in te schakelen.", "Start view": "Start weergave", "This is the screen that workspace members will first see when they sign in.": "Dit is het eerste scherm dat team-leden zien wanneer ze inloggen.", - "Danger": "Danger", + "Danger": "Gevaar", "You can delete this entire workspace including collections, documents, and users.": "You can delete this entire workspace including collections, documents, and users.", "Export data": "Exporteer gegevens", "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to {{ userEmail }} when it’s complete.": "Een volledige export kan enige tijd kosten, overweeg om één enkel document of collectie te exporteren. De geëxporteerde gegevens zijn een zip van je documenten in Markdown formaat. Je kunt deze pagina verlaten zodra de export is gestart. Als je notificaties aan hebt staan, we zullen een link naar {{ userEmail }} e-mailen wanneer het afgerond is.", @@ -886,7 +889,7 @@ "There are no templates just yet.": "Er zijn nog geen sjablonen.", "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier is een platform waarmee {{appName}} eenvoudig kan worden geïntegreerd met duizenden andere zakelijke tools. Automatiseer je workflows, synchroniseer gegevens en meer.", "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.", - "Confirmation code": "Confirmation code", + "Confirmation code": "Bevestigingscode", "Deleting the <1>{{workspaceName}} workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.": "Deleting the <1>{{workspaceName}} workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.", "Please note that workspaces are completely separate. They can have a different domain, settings, users, and billing.": "Please note that workspaces are completely separate. They can have a different domain, settings, users, and billing.", "Workspace name": "Naam workspace", @@ -895,7 +898,7 @@ "Trash is empty at the moment.": "Prullenbak is momenteel leeg.", "A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.", "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.": "Weet je het zeker? Het verwijderen van je account zal de identificerende gegevens van je gebruiker vernietigen en kan niet ongedaan worden gemaakt. Je wordt onmiddellijk uitgelogd bij {{appName}} en al je API-tokens worden ingetrokken.", - "Delete my account": "Delete my account", + "Delete my account": "Verwijder mijn account", "Today": "Vandaag", "Yesterday": "Gisteren", "Last week": "Afgelopen week", diff --git a/shared/i18n/locales/pl_PL/translation.json b/shared/i18n/locales/pl_PL/translation.json index 1debd597e17a..eb150aafe400 100644 --- a/shared/i18n/locales/pl_PL/translation.json +++ b/shared/i18n/locales/pl_PL/translation.json @@ -132,6 +132,7 @@ "Submenu": "Podmenu", "Collections could not be loaded, please reload the app": "Nie można załadować kolekcji, proszę odświeżyć aplikację", "Default collection": "Domyślna kolekcja", + "Install now": "Install now", "Deleted Collection": "Usunięta kolekcja", "Unpin": "Odepnij", "Search collections & documents": "Przeszukaj kolekcje i dokumenty", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} nieopublikowane", "{{userName}} moved": "Użytkownik {{userName}} przeniósł", "Export started": "Eksport rozpoczęty", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Plik ZIP zawierający obrazy i dokumenty w formacie Markdown.", "A ZIP file containing the images, and documents as HTML files.": "Plik ZIP zawierający obrazy i dokumenty w postaci plików HTML.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Uporządkowane dane, które mogą być użyte do przeniesienia danych do innej kompatybilnej instancji {{ appName }}.", diff --git a/shared/i18n/locales/pt_BR/translation.json b/shared/i18n/locales/pt_BR/translation.json index 20a09a04f636..09a3e9bfe409 100644 --- a/shared/i18n/locales/pt_BR/translation.json +++ b/shared/i18n/locales/pt_BR/translation.json @@ -132,6 +132,7 @@ "Submenu": "Submenu", "Collections could not be loaded, please reload the app": "Não foi possível carregar as coleções, recarregue o aplicativo", "Default collection": "Coleção padrão", + "Install now": "Install now", "Deleted Collection": "Excluir Coleção", "Unpin": "Desafixar", "Search collections & documents": "Pesquisar coleções e documentos", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} despublicou", "{{userName}} moved": "{{userName}} moveu", "Export started": "Exportação iniciada", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Um arquivo ZIP contendo as imagens e documentos no formato Markdown.", "A ZIP file containing the images, and documents as HTML files.": "Um arquivo ZIP contendo as imagens e documentos no formato HTML.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Dados estruturados que podem ser usados para transferir dados para outra instância {{ appName }} compatível.", diff --git a/shared/i18n/locales/pt_PT/translation.json b/shared/i18n/locales/pt_PT/translation.json index 13c6f31e71f9..322948d64330 100644 --- a/shared/i18n/locales/pt_PT/translation.json +++ b/shared/i18n/locales/pt_PT/translation.json @@ -132,6 +132,7 @@ "Submenu": "Submenu", "Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app", "Default collection": "Default collection", + "Install now": "Install now", "Deleted Collection": "Coleção eliminada", "Unpin": "Tirar pino", "Search collections & documents": "Search collections & documents", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} unpublished", "{{userName}} moved": "{{userName}} moved", "Export started": "Export started", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "A ZIP file containing the images, and documents in the Markdown format.", "A ZIP file containing the images, and documents as HTML files.": "A ZIP file containing the images, and documents as HTML files.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Structured data that can be used to transfer data to another compatible {{ appName }} instance.", diff --git a/shared/i18n/locales/sv_SE/translation.json b/shared/i18n/locales/sv_SE/translation.json index 692d80e9f274..a3a0d8c70b80 100644 --- a/shared/i18n/locales/sv_SE/translation.json +++ b/shared/i18n/locales/sv_SE/translation.json @@ -132,6 +132,7 @@ "Submenu": "Undermeny", "Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app", "Default collection": "Default collection", + "Install now": "Install now", "Deleted Collection": "Deleted Collection", "Unpin": "Lossa", "Search collections & documents": "Sök i samlingar och dokument", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} opublicerade", "{{userName}} moved": "{{userName}} flyttade", "Export started": "Exportering startad", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "A ZIP file containing the images, and documents in the Markdown format.", "A ZIP file containing the images, and documents as HTML files.": "A ZIP file containing the images, and documents as HTML files.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Structured data that can be used to transfer data to another compatible {{ appName }} instance.", diff --git a/shared/i18n/locales/th_TH/translation.json b/shared/i18n/locales/th_TH/translation.json index 161e86b475e7..172fd5a9d2d2 100644 --- a/shared/i18n/locales/th_TH/translation.json +++ b/shared/i18n/locales/th_TH/translation.json @@ -132,6 +132,7 @@ "Submenu": "เมนูย่อย", "Collections could not be loaded, please reload the app": "ไม่สามารถโหลดคอลเลคชั่นได้ โปรดรีโหลดแอป", "Default collection": "คอลเลคชั่นเริ่มต้น", + "Install now": "Install now", "Deleted Collection": "คอลเลคชั่นที่ถูกลบ", "Unpin": "เลิกปักหมุด", "Search collections & documents": "Search collections & documents", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} unpublished", "{{userName}} moved": "{{userName}} moved", "Export started": "Export started", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "A ZIP file containing the images, and documents in the Markdown format.", "A ZIP file containing the images, and documents as HTML files.": "A ZIP file containing the images, and documents as HTML files.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Structured data that can be used to transfer data to another compatible {{ appName }} instance.", diff --git a/shared/i18n/locales/tr_TR/translation.json b/shared/i18n/locales/tr_TR/translation.json index bade22377628..86197b52d5fb 100644 --- a/shared/i18n/locales/tr_TR/translation.json +++ b/shared/i18n/locales/tr_TR/translation.json @@ -132,6 +132,7 @@ "Submenu": "Alt menü", "Collections could not be loaded, please reload the app": "Koleksiyonlar yüklenemedi, lütfen uygulamayı yeniden yükleyin", "Default collection": "Varsayılan koleksiyon", + "Install now": "Install now", "Deleted Collection": "Silinmiş Koleksiyon", "Unpin": "Sabitlemeyi kaldır", "Search collections & documents": "Koleksiyonlarda ve belgelerde arama yapın", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} yayınlanmamış", "{{userName}} moved": "{{userName}} taşıdı", "Export started": "Dışa aktarma başladı", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Görselleri ve dokümanları Markdown biçiminde içeren bir ZIP dosyası.", "A ZIP file containing the images, and documents as HTML files.": "Görselleri ve dokümanları HTML dosyaları olarak içeren bir ZIP dosyası.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Verileri başka bir uyumlu {{ appName }} uygulamasına aktarmak için kullanılabilecek yapılandırılmış veri.", diff --git a/shared/i18n/locales/uk_UA/translation.json b/shared/i18n/locales/uk_UA/translation.json index 6b9f1ae11fd1..f04a90e8491a 100644 --- a/shared/i18n/locales/uk_UA/translation.json +++ b/shared/i18n/locales/uk_UA/translation.json @@ -132,6 +132,7 @@ "Submenu": "Підменю", "Collections could not be loaded, please reload the app": "Не вдалося завантажити колекції. Перезавантажте програму", "Default collection": "Колекція за замовчуванням", + "Install now": "Install now", "Deleted Collection": "Видалена колекція", "Unpin": "Відкріпити", "Search collections & documents": "Пошук колекцій і документів", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} знятий з публікації", "{{userName}} moved": "{{userName}} переміщено", "Export started": "Експорт розпочато", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "ZIP-файл із зображеннями та документами у форматі Markdown.", "A ZIP file containing the images, and documents as HTML files.": "ZIP-файл із зображеннями та документами у форматі HTML.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Структуровані дані, які можна використовувати для передачі даних до іншого сумісного екземпляра {{ appName }}.", diff --git a/shared/i18n/locales/vi_VN/translation.json b/shared/i18n/locales/vi_VN/translation.json index 6459b4543b9f..3435008039b9 100644 --- a/shared/i18n/locales/vi_VN/translation.json +++ b/shared/i18n/locales/vi_VN/translation.json @@ -132,6 +132,7 @@ "Submenu": "Menu phụ", "Collections could not be loaded, please reload the app": "Không thể tải bộ sưu tập, vui lòng tải lại ứng dụng", "Default collection": "Bộ sưu tập mặc định", + "Install now": "Install now", "Deleted Collection": "Xóa Bộ Sưu Tập", "Unpin": "Bỏ ghim", "Search collections & documents": "Tìm kiếm bộ sưu tập và tài liệu", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} bỏ đăng tải", "{{userName}} moved": "{{userName}} đã chuyển", "Export started": "Đã bắt đầu xuất", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "Tệp ZIP chứa hình ảnh và tài liệu ở định dạng Markdown.", "A ZIP file containing the images, and documents as HTML files.": "Tệp ZIP chứa hình ảnh và tài liệu ở định dạng Markdown.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Dữ liệu có cấu trúc có thể được sử dụng để truyền dữ liệu sang một phiên bản {{ appName }} tương thích khác.", diff --git a/shared/i18n/locales/zh_CN/translation.json b/shared/i18n/locales/zh_CN/translation.json index 59ef4587ca52..37a34b1d16ef 100644 --- a/shared/i18n/locales/zh_CN/translation.json +++ b/shared/i18n/locales/zh_CN/translation.json @@ -132,6 +132,7 @@ "Submenu": "子菜单", "Collections could not be loaded, please reload the app": "无法加载收藏夹,请重新加载应用", "Default collection": "默认文档集", + "Install now": "Install now", "Deleted Collection": "删除文档集", "Unpin": "取消置顶", "Search collections & documents": "搜索文档集和文档", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} 未发布", "{{userName}} moved": "已被 {{userName}} 移动", "Export started": "已开始导出", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "包含图像和 Markdown 格式文档的 ZIP 文件。", "A ZIP file containing the images, and documents as HTML files.": "包含图像和 HTML 格式文档的 ZIP 文件。", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "结构化数据可以用于传输数据到另一个兼容的 {{ appName }} 实例。", @@ -790,7 +793,7 @@ "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to {{ userEmail }} when it’s complete.": "完整导出可能需要一些时间,请考虑导出单个文档或集合。导出的数据是 Markdown 格式文档的压缩包。导出开始后,您可以离开此页面。此外,如果你开启了通知,导出完成后,我们会发送链接至邮箱 {{ userEmail }}。", "Recent exports": "最近导出", "Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.": "管理可选功能和测试功能。更改这些设置将影响工作组内所有成员的使用。", - "Separate editing": "Separate editing", + "Separate editing": "独立编辑", "When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences.": "When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences.", "Commenting": "评论", "When enabled team members can add comments to documents.": "启用后,团队成员可以向文档添加评论。", @@ -843,7 +846,7 @@ "Show a hand cursor when hovering over interactive elements.": "将鼠标悬停在交互式元素上时显示手形光标。", "Show line numbers": "显示行号", "Show line numbers on code blocks in documents.": "在文档中的代码块上显示行号。", - "When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.": "When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.", + "When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.": "启用后,文档具有单独的编辑模式。禁用时,文档在您拥有权限时始终可编辑。", "Remember previous location": "记住上一次的位置", "Automatically return to the document you were last viewing when the app is re-opened.": "重新打开应用程序时自动返回到您上次查看的文档。", "You may delete your account at any time, note that this is unrecoverable": "您可以随时删除您的帐户,请注意删除后将无法恢复该账号", diff --git a/shared/i18n/locales/zh_TW/translation.json b/shared/i18n/locales/zh_TW/translation.json index fc6ae53acce8..a8d6983c7015 100644 --- a/shared/i18n/locales/zh_TW/translation.json +++ b/shared/i18n/locales/zh_TW/translation.json @@ -132,6 +132,7 @@ "Submenu": "子選單", "Collections could not be loaded, please reload the app": "文件集無法被載入,請重新整理 App", "Default collection": "預設文件集", + "Install now": "Install now", "Deleted Collection": "刪除的文件集", "Unpin": "取消釘選", "Search collections & documents": "搜尋文件集與文件", @@ -192,6 +193,8 @@ "{{userName}} unpublished": "{{userName}} 已取消將其發布", "{{userName}} moved": "{{userName}} 將其移動", "Export started": "已經開始匯出", + "Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon", + "View": "View", "A ZIP file containing the images, and documents in the Markdown format.": "包含圖片和 Markdown 格式文件的 ZIP 文件。", "A ZIP file containing the images, and documents as HTML files.": "包含圖片和 HTML 格式文件的 ZIP 文件。", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "可用於將資料傳輸到另一個相容的 {{ appName }} 實例的結構化資料。", From 964d2b6bb35bec4a101043a27d28403eafd038ee Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 11:31:42 -0400 Subject: [PATCH 012/241] Allow use of useCurrentUser/useCurrentTeam hooks in unauthenticated components --- app/components/Authenticated.tsx | 8 +++--- app/components/AuthenticatedLayout.tsx | 13 +++++---- app/components/Editor.tsx | 5 ++-- app/components/Sidebar/Shared.tsx | 6 +++-- app/components/Sidebar/Sidebar.tsx | 5 ++-- app/editor/components/MentionMenu.tsx | 8 +++--- app/hooks/useCurrentTeam.ts | 21 ++++++++++++--- app/hooks/useCurrentUser.ts | 21 ++++++++++++--- app/hooks/useUserLocale.ts | 8 +++--- .../AddGroupsToCollection.tsx | 16 ++++------- app/scenes/Document/Shared.tsx | 8 +++--- app/scenes/Document/components/DataLoader.tsx | 27 +++++++++---------- .../Document/components/DocumentMeta.tsx | 7 ++--- app/scenes/Document/components/Editor.tsx | 7 +++-- app/scenes/Document/components/Header.tsx | 7 +++-- app/scenes/GroupMembers/AddPeopleToGroup.tsx | 9 +++---- app/scenes/Login/index.tsx | 4 ++- 17 files changed, 109 insertions(+), 71 deletions(-) diff --git a/app/components/Authenticated.tsx b/app/components/Authenticated.tsx index 924e5da8de1f..02eec34ea9e8 100644 --- a/app/components/Authenticated.tsx +++ b/app/components/Authenticated.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Redirect } from "react-router-dom"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; import { changeLanguage } from "~/utils/language"; import LoadingIndicator from "./LoadingIndicator"; @@ -13,10 +14,11 @@ type Props = { const Authenticated = ({ children }: Props) => { const { auth } = useStores(); const { i18n } = useTranslation(); - const language = auth.user?.language; + const user = useCurrentUser({ rejectOnEmpty: false }); + const language = user?.language; - // Watching for language changes here as this is the earliest point we have - // the user available and means we can start loading translations faster + // Watching for language changes here as this is the earliest point we might have the user + // available and means we can start loading translations faster React.useEffect(() => { void changeLanguage(language, i18n); }, [i18n, language]); diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index cad6c54ac5f8..107b645f119e 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -12,6 +12,7 @@ import Sidebar from "~/components/Sidebar"; import SidebarRight from "~/components/Sidebar/Right"; import SettingsSidebar from "~/components/Sidebar/Settings"; import type { Editor as TEditor } from "~/editor"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import history from "~/utils/history"; @@ -45,7 +46,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { const { ui, auth } = useStores(); const location = useLocation(); const can = usePolicy(ui.activeCollectionId); - const { user, team } = auth; + const team = useCurrentTeam(); const documentContext = useLocalStore(() => ({ editor: null, setEditor: (editor: TEditor) => { @@ -76,16 +77,14 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { return ; } - const showSidebar = auth.authenticated && user && team; - - const sidebar = showSidebar ? ( + const sidebar = ( - ) : undefined; + ); const showHistory = !!matchPath(location.pathname, { path: matchDocumentHistory, @@ -98,7 +97,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { !showHistory && ui.activeDocumentId && ui.commentsExpanded.includes(ui.activeDocumentId) && - team?.getPreference(TeamPreference.Commenting); + team.getPreference(TeamPreference.Commenting); const sidebarRight = ( { return ( - + diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 2304dda10ba5..4c0dafa585b8 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -21,6 +21,7 @@ import ClickablePadding from "~/components/ClickablePadding"; import ErrorBoundary from "~/components/ErrorBoundary"; import HoverPreview from "~/components/HoverPreview"; import type { Props as EditorProps, Editor as SharedEditor } from "~/editor"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useDictionary from "~/hooks/useDictionary"; import useEmbeds from "~/hooks/useEmbeds"; import useStores from "~/hooks/useStores"; @@ -65,12 +66,12 @@ function Editor(props: Props, ref: React.RefObject | null) { } = props; const userLocale = useUserLocale(); const locale = dateLocale(userLocale); - const { auth, comments, documents } = useStores(); + const { comments, documents } = useStores(); const dictionary = useDictionary(); const embeds = useEmbeds(!shareId); const history = useHistory(); const localRef = React.useRef(); - const preferences = auth.user?.preferences; + const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences; const previousHeadings = React.useRef(null); const [activeLinkElement, setActiveLink] = React.useState(null); diff --git a/app/components/Sidebar/Shared.tsx b/app/components/Sidebar/Shared.tsx index 7efaf46ec5a3..faa63a4af8ab 100644 --- a/app/components/Sidebar/Shared.tsx +++ b/app/components/Sidebar/Shared.tsx @@ -5,6 +5,7 @@ import styled from "styled-components"; import { NavigationNode } from "@shared/types"; import Scrollable from "~/components/Scrollable"; import SearchPopover from "~/components/SearchPopover"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; import history from "~/utils/history"; import { homePath, sharedDocumentPath } from "~/utils/routeHelpers"; @@ -22,7 +23,8 @@ type Props = { function SharedSidebar({ rootNode, shareId }: Props) { const team = useTeamContext(); - const { ui, documents, auth } = useStores(); + const user = useCurrentUser({ rejectOnEmpty: false }); + const { ui, documents } = useStores(); const { t } = useTranslation(); return ( @@ -33,7 +35,7 @@ function SharedSidebar({ rootNode, shareId }: Props) { image={} onClick={() => history.push( - auth.user ? homePath() : sharedDocumentPath(shareId, rootNode.url) + user ? homePath() : sharedDocumentPath(shareId, rootNode.url) ) } /> diff --git a/app/components/Sidebar/Sidebar.tsx b/app/components/Sidebar/Sidebar.tsx index 60227e6945ad..78db3a391b8a 100644 --- a/app/components/Sidebar/Sidebar.tsx +++ b/app/components/Sidebar/Sidebar.tsx @@ -6,6 +6,7 @@ import styled, { css, useTheme } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { depths, s } from "@shared/styles"; import Flex from "~/components/Flex"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useMenuContext from "~/hooks/useMenuContext"; import usePrevious from "~/hooks/usePrevious"; import useStores from "~/hooks/useStores"; @@ -33,11 +34,11 @@ const Sidebar = React.forwardRef(function _Sidebar( ) { const [isCollapsing, setCollapsing] = React.useState(false); const theme = useTheme(); - const { ui, auth } = useStores(); + const { ui } = useStores(); const location = useLocation(); const previousLocation = usePrevious(location); const { isMenuOpen } = useMenuContext(); - const { user } = auth; + const user = useCurrentUser({ rejectOnEmpty: false }); const width = ui.sidebarWidth; const collapsed = ui.sidebarIsClosed && !isMenuOpen; const maxWidth = theme.sidebarMaxWidth; diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index ed9ed384c547..af0edc10f0fa 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -10,6 +10,7 @@ import User from "~/models/User"; import Avatar from "~/components/Avatar"; import { AvatarSize } from "~/components/Avatar/Avatar"; import Flex from "~/components/Flex"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; import MentionMenuItem from "./MentionMenuItem"; @@ -39,8 +40,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) { const [loaded, setLoaded] = React.useState(false); const [items, setItems] = React.useState([]); const { t } = useTranslation(); - const { users, auth } = useStores(); + const { users } = useStores(); const location = useLocation(); + const user = useCurrentUser({ rejectOnEmpty: false }); const documentId = parseDocumentSlug(location.pathname); const { data, loading, request } = useRequest( React.useCallback( @@ -69,7 +71,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) { id: v4(), type: MentionType.User, modelId: user.id, - actorId: auth.user?.id, + actorId: user?.id, label: user.name, }, })); @@ -77,7 +79,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) { setItems(items); setLoaded(true); } - }, [auth.user?.id, loading, data]); + }, [user?.id, loading, data]); // Prevent showing the menu until we have data otherwise it will be positioned // incorrectly due to the height being unknown. diff --git a/app/hooks/useCurrentTeam.ts b/app/hooks/useCurrentTeam.ts index db5c386e72db..d64579ada7da 100644 --- a/app/hooks/useCurrentTeam.ts +++ b/app/hooks/useCurrentTeam.ts @@ -1,8 +1,23 @@ import invariant from "invariant"; +import Team from "~/models/Team"; import useStores from "./useStores"; -export default function useCurrentTeam() { +/** + * Returns the current team, or undefined if there is no current team and `rejectOnEmpty` is set to + * false. + * + * @param options.rejectOnEmpty - If true, throws an error if there is no current team. Defaults to true. + */ +function useCurrentTeam(options: { rejectOnEmpty: false }): Team | undefined; +function useCurrentTeam(options?: { rejectOnEmpty: true }): Team; +function useCurrentTeam({ + rejectOnEmpty = true, +}: { rejectOnEmpty?: boolean } = {}) { const { auth } = useStores(); - invariant(auth.team, "team required"); - return auth.team; + if (rejectOnEmpty) { + invariant(auth.team, "team required"); + } + return auth.team || undefined; } + +export default useCurrentTeam; diff --git a/app/hooks/useCurrentUser.ts b/app/hooks/useCurrentUser.ts index 53fb54c8a9e9..267b39dcee33 100644 --- a/app/hooks/useCurrentUser.ts +++ b/app/hooks/useCurrentUser.ts @@ -1,8 +1,23 @@ import invariant from "invariant"; +import User from "~/models/User"; import useStores from "./useStores"; -export default function useCurrentUser() { +/** + * Returns the current user, or undefined if there is no current user and `rejectOnEmpty` is set to + * false. + * + * @param options.rejectOnEmpty - If true, throws an error if there is no current user. Defaults to true. + */ +function useCurrentUser(options: { rejectOnEmpty: false }): User | undefined; +function useCurrentUser(options?: { rejectOnEmpty: true }): User; +function useCurrentUser({ + rejectOnEmpty = true, +}: { rejectOnEmpty?: boolean } = {}) { const { auth } = useStores(); - invariant(auth.user, "user required"); - return auth.user; + if (rejectOnEmpty) { + invariant(auth.user, "user required"); + } + return auth.user || undefined; } + +export default useCurrentUser; diff --git a/app/hooks/useUserLocale.ts b/app/hooks/useUserLocale.ts index f8628d9fd966..7a981589bebd 100644 --- a/app/hooks/useUserLocale.ts +++ b/app/hooks/useUserLocale.ts @@ -1,4 +1,4 @@ -import useStores from "./useStores"; +import useCurrentUser from "./useCurrentUser"; /** * Returns the user's locale, or undefined if the user is not logged in. @@ -7,12 +7,12 @@ import useStores from "./useStores"; * @returns The user's locale, or undefined if the user is not logged in */ export default function useUserLocale(languageCode?: boolean) { - const { auth } = useStores(); + const user = useCurrentUser({ rejectOnEmpty: false }); - if (!auth.user?.language) { + if (!user?.language) { return undefined; } - const { language } = auth.user; + const { language } = user; return languageCode ? language.split("_")[0] : language; } diff --git a/app/scenes/CollectionPermissions/AddGroupsToCollection.tsx b/app/scenes/CollectionPermissions/AddGroupsToCollection.tsx index b3dd13706ae6..8a89957de1fb 100644 --- a/app/scenes/CollectionPermissions/AddGroupsToCollection.tsx +++ b/app/scenes/CollectionPermissions/AddGroupsToCollection.tsx @@ -17,6 +17,7 @@ import Modal from "~/components/Modal"; import PaginatedList from "~/components/PaginatedList"; import Text from "~/components/Text"; import useBoolean from "~/hooks/useBoolean"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; import useStores from "~/hooks/useStores"; type Props = { @@ -29,11 +30,11 @@ function AddGroupsToCollection(props: Props) { const [newGroupModalOpen, handleNewGroupModalOpen, handleNewGroupModalClose] = useBoolean(false); const [query, setQuery] = React.useState(""); - - const { auth, collectionGroupMemberships, groups, policies } = useStores(); - const { fetchPage: fetchGroups } = groups; - + const team = useCurrentTeam(); + const { collectionGroupMemberships, groups, policies } = useStores(); const { t } = useTranslation(); + const { fetchPage: fetchGroups } = groups; + const can = policies.abilities(team.id); const debouncedFetch = React.useMemo( () => debounce((query) => fetchGroups({ query }), 250), @@ -65,13 +66,6 @@ function AddGroupsToCollection(props: Props) { } }; - const { user, team } = auth; - if (!user || !team) { - return null; - } - - const can = policies.abilities(team.id); - return ( {can.createGroup ? ( diff --git a/app/scenes/Document/Shared.tsx b/app/scenes/Document/Shared.tsx index dd4d2aa3e277..756b9eab77ee 100644 --- a/app/scenes/Document/Shared.tsx +++ b/app/scenes/Document/Shared.tsx @@ -18,6 +18,7 @@ import { TeamContext } from "~/components/TeamContext"; import Text from "~/components/Text"; import env from "~/env"; import useBuildTheme from "~/hooks/useBuildTheme"; +import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { AuthorizationError, OfflineError } from "~/utils/errors"; @@ -83,8 +84,9 @@ function useDocumentId(documentSlug: string, response?: Response) { } function SharedDocumentScene(props: Props) { - const { ui, auth } = useStores(); + const { ui } = useStores(); const location = useLocation(); + const user = useCurrentUser({ rejectOnEmpty: false }); const searchParams = React.useMemo( () => new URLSearchParams(location.search), [location.search] @@ -104,10 +106,10 @@ function SharedDocumentScene(props: Props) { const theme = useBuildTheme(response?.team?.customTheme, themeOverride); React.useEffect(() => { - if (!auth.user) { + if (!user) { void changeLanguage(detectLanguage(), i18n); } - }, [auth, i18n]); + }, [user, i18n]); // ensure the wider page color always matches the theme React.useEffect(() => { diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 891c4b49f952..d583df41d967 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -7,6 +7,8 @@ import Document from "~/models/Document"; import Revision from "~/models/Revision"; import Error404 from "~/scenes/Error404"; import ErrorOffline from "~/scenes/ErrorOffline"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import Logger from "~/utils/Logger"; @@ -16,12 +18,16 @@ import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers"; import Loading from "./Loading"; type Params = { + /** The document urlId + slugified title */ documentSlug: string; + /** A specific revision id to load. */ revisionId?: string; + /** The share ID to use to load data. */ shareId?: string; }; type LocationState = { + /** The document title, if preloaded */ title?: string; restore?: boolean; revisionId?: string; @@ -41,17 +47,10 @@ type Props = RouteComponentProps & { }; function DataLoader({ match, children }: Props) { - const { - ui, - views, - shares, - comments, - documents, - auth, - revisions, - subscriptions, - } = useStores(); - const { team } = auth; + const { ui, views, shares, comments, documents, revisions, subscriptions } = + useStores(); + const team = useCurrentTeam(); + const user = useCurrentUser(); const [error, setError] = React.useState(null); const { revisionId, shareId, documentSlug } = match.params; @@ -73,7 +72,7 @@ function DataLoader({ match, children }: Props) { : undefined; const isEditRoute = match.path === matchDocumentEdit || match.path.startsWith(settingsPath()); - const isEditing = isEditRoute || !auth.user?.separateEditMode; + const isEditing = isEditRoute || !user?.separateEditMode; const can = usePolicy(document?.id); const location = useLocation(); @@ -180,7 +179,7 @@ function DataLoader({ match, children }: Props) { // Prevents unauthorized request to load share information for the document // when viewing a public share link if (can.read) { - if (team?.getPreference(TeamPreference.Commenting)) { + if (team.getPreference(TeamPreference.Commenting)) { void comments.fetchDocumentComments(document.id, { limit: 100, }); @@ -199,7 +198,7 @@ function DataLoader({ match, children }: Props) { return error instanceof OfflineError ? : ; } - if (!document || !team || (revisionId && !revision)) { + if (!document || (revisionId && !revision)) { return ( <> diff --git a/app/scenes/Document/components/DocumentMeta.tsx b/app/scenes/Document/components/DocumentMeta.tsx index 941f2adb6ae6..d87ea0a606f0 100644 --- a/app/scenes/Document/components/DocumentMeta.tsx +++ b/app/scenes/Document/components/DocumentMeta.tsx @@ -10,6 +10,7 @@ import Document from "~/models/Document"; import Revision from "~/models/Revision"; import DocumentMeta from "~/components/DocumentMeta"; import Fade from "~/components/Fade"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; import useStores from "~/hooks/useStores"; import { documentPath, documentInsightsPath } from "~/utils/routeHelpers"; @@ -29,10 +30,10 @@ function TitleDocumentMeta({ revision, ...rest }: Props) { - const { auth, views, comments, ui } = useStores(); + const { views, comments, ui } = useStores(); const { t } = useTranslation(); - const { team } = auth; const match = useRouteMatch(); + const team = useCurrentTeam(); const documentViews = useObserver(() => views.inDocument(document.id)); const totalViewers = documentViews.length; const onlyYou = totalViewers === 1 && documentViews[0].userId; @@ -45,7 +46,7 @@ function TitleDocumentMeta({ return ( - {team?.getPreference(TeamPreference.Commenting) && ( + {team.getPreference(TeamPreference.Commenting) && ( <>  •  ) { const { t } = useTranslation(); const match = useRouteMatch(); const focusedComment = useFocusedComment(); - const { ui, comments, auth } = useStores(); - const { user, team } = auth; + const { ui, comments } = useStores(); + const user = useCurrentUser({ rejectOnEmpty: false }); + const team = useCurrentTeam({ rejectOnEmpty: false }); const history = useHistory(); const { document, diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index 243ed512cb37..67019526a11e 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -29,6 +29,8 @@ import { publishDocument } from "~/actions/definitions/documents"; import { navigateToTemplateSettings } from "~/actions/definitions/navigation"; import { restoreRevision } from "~/actions/definitions/revisions"; import useActionContext from "~/hooks/useActionContext"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useMobile from "~/hooks/useMobile"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; @@ -84,10 +86,11 @@ function DocumentHeader({ headings, }: Props) { const { t } = useTranslation(); - const { ui, auth } = useStores(); + const { ui } = useStores(); const theme = useTheme(); + const team = useCurrentTeam({ rejectOnEmpty: false }); + const user = useCurrentUser({ rejectOnEmpty: false }); const { resolvedTheme } = ui; - const { team, user } = auth; const isMobile = useMobile(); const isRevision = !!revision; const isEditingFocus = useEditingFocus(); diff --git a/app/scenes/GroupMembers/AddPeopleToGroup.tsx b/app/scenes/GroupMembers/AddPeopleToGroup.tsx index 15d558391869..f8472743b1f3 100644 --- a/app/scenes/GroupMembers/AddPeopleToGroup.tsx +++ b/app/scenes/GroupMembers/AddPeopleToGroup.tsx @@ -16,6 +16,7 @@ import Modal from "~/components/Modal"; import PaginatedList from "~/components/PaginatedList"; import Text from "~/components/Text"; import useBoolean from "~/hooks/useBoolean"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; import useStores from "~/hooks/useStores"; import GroupMemberListItem from "./components/GroupMemberListItem"; @@ -27,7 +28,8 @@ type Props = { function AddPeopleToGroup(props: Props) { const { group } = props; - const { users, auth, groupMemberships } = useStores(); + const { users, groupMemberships } = useStores(); + const team = useCurrentTeam(); const { t } = useTranslation(); const [query, setQuery] = React.useState(""); @@ -69,11 +71,6 @@ function AddPeopleToGroup(props: Props) { } }; - const { user, team } = auth; - if (!user || !team) { - return null; - } - return ( diff --git a/app/scenes/Login/index.tsx b/app/scenes/Login/index.tsx index de42458902fb..57f82919cefa 100644 --- a/app/scenes/Login/index.tsx +++ b/app/scenes/Login/index.tsx @@ -22,6 +22,7 @@ import PageTitle from "~/components/PageTitle"; import TeamLogo from "~/components/TeamLogo"; import Text from "~/components/Text"; import env from "~/env"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useLastVisitedPath from "~/hooks/useLastVisitedPath"; import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; @@ -43,12 +44,13 @@ function Login({ children }: Props) { const notice = query.get("notice"); const { t } = useTranslation(); + const user = useCurrentUser({ rejectOnEmpty: false }); const { auth } = useStores(); const { config } = auth; const [error, setError] = React.useState(null); const [emailLinkSentTo, setEmailLinkSentTo] = React.useState(""); const isCreate = location.pathname === "/create"; - const rememberLastPath = !!auth.user?.getPreference( + const rememberLastPath = !!user?.getPreference( UserPreference.RememberLastPath ); const [lastVisitedPath] = useLastVisitedPath(); From 3cd90f3e74d4262cd29bc4a8ef34b3116a34b46a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 11:37:41 -0400 Subject: [PATCH 013/241] Remove duplicative test --- server/routes/api/groups/groups.test.ts | 30 ++++++------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/server/routes/api/groups/groups.test.ts b/server/routes/api/groups/groups.test.ts index 1e396172a4e8..6ac2c7a92afd 100644 --- a/server/routes/api/groups/groups.test.ts +++ b/server/routes/api/groups/groups.test.ts @@ -261,35 +261,19 @@ describe("#groups.list", () => { it("should allow to find a group by its name", async () => { const user = await buildUser(); - const group = await buildGroup({ - teamId: user.teamId, - }); - const anotherGroup = await buildGroup({ - teamId: user.teamId, - }); + const group = await buildGroup({ teamId: user.teamId }); + await buildGroup({ teamId: user.teamId }); - const unfilteredRes = await server.post("/api/groups.list", { - body: { - token: user.getJwtToken(), - }, - }); - const body = await unfilteredRes.json(); - - expect(unfilteredRes.status).toEqual(200); - expect(body.data.groups.length).toEqual(2); - expect(body.data.groups[0].id).toEqual(anotherGroup.id); - expect(body.data.groups[1].id).toEqual(group.id); - - const anotherRes = await server.post("/api/groups.list", { + const res = await server.post("/api/groups.list", { body: { name: group.name, token: user.getJwtToken(), }, }); - const anotherBody = await anotherRes.json(); - expect(anotherRes.status).toEqual(200); - expect(anotherBody.data.groups.length).toEqual(1); - expect(anotherBody.data.groups[0].id).toEqual(group.id); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.groups.length).toEqual(1); + expect(body.data.groups[0].id).toEqual(group.id); }); }); From 1e847dc1cf8ee6ffadce8666eec98751f0a07848 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 12:43:50 -0400 Subject: [PATCH 014/241] Cleanup and refactor `AuthStore` (#6086) --- app/components/LanguagePrompt.tsx | 6 +- .../Document/components/MultiplayerEditor.tsx | 2 +- app/scenes/Settings/Details.tsx | 23 +-- app/scenes/Settings/Features.tsx | 13 +- app/scenes/Settings/Preferences.tsx | 15 +- app/scenes/Settings/Profile.tsx | 12 +- app/scenes/Settings/Security.tsx | 6 +- .../Settings/components/DomainManagement.tsx | 10 +- app/stores/AuthStore.ts | 172 +++++++----------- app/stores/RootStore.ts | 7 +- server/routes/api/teams/teams.ts | 54 +++--- 11 files changed, 136 insertions(+), 184 deletions(-) diff --git a/app/components/LanguagePrompt.tsx b/app/components/LanguagePrompt.tsx index 73425f4ed5ae..c84c6b6ec8af 100644 --- a/app/components/LanguagePrompt.tsx +++ b/app/components/LanguagePrompt.tsx @@ -42,7 +42,7 @@ function Icon({ className }: { className?: string }) { } export default function LanguagePrompt() { - const { auth, ui } = useStores(); + const { ui } = useStores(); const { t } = useTranslation(); const user = useCurrentUser(); const language = detectLanguage(); @@ -75,9 +75,7 @@ export default function LanguagePrompt() { { ui.setLanguagePromptDismissed(); - await auth.updateUser({ - language, - }); + await user.save({ language }); }} > {t("Change Language")} diff --git a/app/scenes/Document/components/MultiplayerEditor.tsx b/app/scenes/Document/components/MultiplayerEditor.tsx index 614fb3f462cd..d4a7a2ddb3cf 100644 --- a/app/scenes/Document/components/MultiplayerEditor.tsx +++ b/app/scenes/Document/components/MultiplayerEditor.tsx @@ -93,7 +93,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { ); provider.on("authenticationFailed", () => { - void auth.fetch().catch(() => { + void auth.fetchAuth().catch(() => { history.replace(homePath()); }); }); diff --git a/app/scenes/Settings/Details.tsx b/app/scenes/Settings/Details.tsx index 79a6a8faeb34..c092583ee5af 100644 --- a/app/scenes/Settings/Details.tsx +++ b/app/scenes/Settings/Details.tsx @@ -28,7 +28,7 @@ import ImageInput from "./components/ImageInput"; import SettingRow from "./components/SettingRow"; function Details() { - const { auth, dialogs, ui } = useStores(); + const { dialogs, ui } = useStores(); const { t } = useTranslation(); const team = useCurrentTeam(); const theme = useTheme(); @@ -65,7 +65,7 @@ function Details() { } try { - await auth.updateTeam({ + await team.save({ name, subdomain, defaultCollectionId, @@ -80,16 +80,7 @@ function Details() { toast.error(err.message); } }, - [ - auth, - name, - subdomain, - defaultCollectionId, - team.preferences, - publicBranding, - customTheme, - t, - ] + [team, name, subdomain, defaultCollectionId, publicBranding, customTheme, t] ); const handleNameChange = React.useCallback( @@ -107,9 +98,7 @@ function Details() { ); const handleAvatarUpload = async (avatarUrl: string) => { - await auth.updateTeam({ - avatarUrl, - }); + await team.save({ avatarUrl }); toast.success(t("Logo updated")); }; @@ -288,8 +277,8 @@ function Details() { /> - {can.delete && ( diff --git a/app/scenes/Settings/Features.tsx b/app/scenes/Settings/Features.tsx index 9c0f1a84f1ea..8e17aaafd9d0 100644 --- a/app/scenes/Settings/Features.tsx +++ b/app/scenes/Settings/Features.tsx @@ -9,23 +9,20 @@ import Scene from "~/components/Scene"; import Switch from "~/components/Switch"; import Text from "~/components/Text"; import useCurrentTeam from "~/hooks/useCurrentTeam"; -import useStores from "~/hooks/useStores"; import SettingRow from "./components/SettingRow"; function Features() { - const { auth } = useStores(); const team = useCurrentTeam(); const { t } = useTranslation(); const handlePreferenceChange = (inverted = false) => async (ev: React.ChangeEvent) => { - const preferences = { - ...team.preferences, - [ev.target.name]: inverted ? !ev.target.checked : ev.target.checked, - }; - - await auth.updateTeam({ preferences }); + team.setPreference( + ev.target.name as TeamPreference, + inverted ? !ev.target.checked : ev.target.checked + ); + await team.save(); toast.success(t("Settings saved")); }; diff --git a/app/scenes/Settings/Preferences.tsx b/app/scenes/Settings/Preferences.tsx index ce9875f9502e..da1f3850fb63 100644 --- a/app/scenes/Settings/Preferences.tsx +++ b/app/scenes/Settings/Preferences.tsx @@ -19,24 +19,23 @@ import SettingRow from "./components/SettingRow"; function Preferences() { const { t } = useTranslation(); - const { dialogs, auth } = useStores(); + const { dialogs } = useStores(); const user = useCurrentUser(); const team = useCurrentTeam(); const handlePreferenceChange = (inverted = false) => async (ev: React.ChangeEvent) => { - const preferences = { - ...user.preferences, - [ev.target.name]: inverted ? !ev.target.checked : ev.target.checked, - }; - - await auth.updateUser({ preferences }); + user.setPreference( + ev.target.name as UserPreference, + inverted ? !ev.target.checked : ev.target.checked + ); + await user.save(); toast.success(t("Preferences saved")); }; const handleLanguageChange = async (language: string) => { - await auth.updateUser({ language }); + await user.save({ language }); toast.success(t("Preferences saved")); }; diff --git a/app/scenes/Settings/Profile.tsx b/app/scenes/Settings/Profile.tsx index a88db05dbc85..f4a3d8db519b 100644 --- a/app/scenes/Settings/Profile.tsx +++ b/app/scenes/Settings/Profile.tsx @@ -9,12 +9,10 @@ import Input from "~/components/Input"; import Scene from "~/components/Scene"; import Text from "~/components/Text"; import useCurrentUser from "~/hooks/useCurrentUser"; -import useStores from "~/hooks/useStores"; import ImageInput from "./components/ImageInput"; import SettingRow from "./components/SettingRow"; const Profile = () => { - const { auth } = useStores(); const user = useCurrentUser(); const form = React.useRef(null); const [name, setName] = React.useState(user.name || ""); @@ -24,9 +22,7 @@ const Profile = () => { ev.preventDefault(); try { - await auth.updateUser({ - name, - }); + await user.save({ name }); toast.success(t("Profile saved")); } catch (err) { toast.error(err.message); @@ -38,9 +34,7 @@ const Profile = () => { }; const handleAvatarUpload = async (avatarUrl: string) => { - await auth.updateUser({ - avatarUrl, - }); + await user.save({ avatarUrl }); toast.success(t("Profile picture updated")); }; @@ -49,7 +43,7 @@ const Profile = () => { }; const isValid = form.current?.checkValidity(); - const { isSaving } = auth; + const { isSaving } = user; return ( }> diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index 2cbbb683d054..c8f18554696f 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -24,7 +24,7 @@ import DomainManagement from "./components/DomainManagement"; import SettingRow from "./components/SettingRow"; function Security() { - const { auth, authenticationProviders, dialogs } = useStores(); + const { authenticationProviders, dialogs } = useStores(); const team = useCurrentTeam(); const { t } = useTranslation(); const theme = useTheme(); @@ -61,13 +61,13 @@ function Security() { async (newData) => { try { setData(newData); - await auth.updateTeam(newData); + await team.save(newData); showSuccessMessage(); } catch (err) { toast.error(err.message); } }, - [auth, showSuccessMessage] + [team, showSuccessMessage] ); const handleChange = React.useCallback( diff --git a/app/scenes/Settings/components/DomainManagement.tsx b/app/scenes/Settings/components/DomainManagement.tsx index 906c10b29f84..5f535fae2845 100644 --- a/app/scenes/Settings/components/DomainManagement.tsx +++ b/app/scenes/Settings/components/DomainManagement.tsx @@ -11,7 +11,6 @@ import Input from "~/components/Input"; import NudeButton from "~/components/NudeButton"; import Tooltip from "~/components/Tooltip"; import useCurrentTeam from "~/hooks/useCurrentTeam"; -import useStores from "~/hooks/useStores"; import SettingRow from "./SettingRow"; type Props = { @@ -19,7 +18,6 @@ type Props = { }; function DomainManagement({ onSuccess }: Props) { - const { auth } = useStores(); const team = useCurrentTeam(); const { t } = useTranslation(); @@ -35,16 +33,14 @@ function DomainManagement({ onSuccess }: Props) { const handleSaveDomains = React.useCallback(async () => { try { - await auth.updateTeam({ - allowedDomains, - }); + await team.save({ allowedDomains }); onSuccess(); setExistingDomainsTouched(false); updateLastKnownDomainCount(allowedDomains.length); } catch (err) { toast.error(err.message); } - }, [auth, allowedDomains, onSuccess]); + }, [team, allowedDomains, onSuccess]); const handleRemoveDomain = async (index: number) => { const newDomains = allowedDomains.filter((_, i) => index !== i); @@ -132,7 +128,7 @@ function DomainManagement({ onSuccess }: Props) { diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index eb767e2c9a45..e7995819f49a 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/react"; import invariant from "invariant"; import { observable, action, computed, autorun, runInAction } from "mobx"; import { getCookie, setCookie, removeCookie } from "tiny-cookie"; -import { CustomTheme, TeamPreferences, UserPreferences } from "@shared/types"; +import { CustomTheme } from "@shared/types"; import Storage from "@shared/utils/Storage"; import { getCookieDomain, parseDomain } from "@shared/utils/domains"; import RootStore from "~/stores/RootStore"; @@ -10,17 +10,19 @@ import Policy from "~/models/Policy"; import Team from "~/models/Team"; import User from "~/models/User"; import env from "~/env"; +import { PartialWithId } from "~/types"; import { client } from "~/utils/ApiClient"; import Desktop from "~/utils/Desktop"; import Logger from "~/utils/Logger"; import isCloudHosted from "~/utils/isCloudHosted"; +import Store from "./base/Store"; const AUTH_STORE = "AUTH_STORE"; const NO_REDIRECT_PATHS = ["/", "/create", "/home", "/logout"]; type PersistedData = { - user?: User; - team?: Team; + user?: PartialWithId; + team?: PartialWithId; collaborationToken?: string; availableTeams?: { id: string; @@ -46,14 +48,14 @@ export type Config = { providers: Provider[]; }; -export default class AuthStore { - /* The user that is currently signed in. */ +export default class AuthStore extends Store { + /* The ID of the user that is currently signed in. */ @observable - user?: User | null; + currentUserId?: string | null; - /* The team that the current user is signed into. */ + /* The ID of the team that is currently signed in. */ @observable - team?: Team | null; + currentTeamId?: string | null; /* A short-lived token to be used to authenticate with the collaboration server. */ @observable @@ -69,21 +71,10 @@ export default class AuthStore { isSignedIn: boolean; }[]; - /* A list of cancan policies for the current user. */ - @observable - policies: Policy[] = []; - /* The authentication provider the user signed in with. */ @observable lastSignedIn?: string | null; - /* Whether the user is currently saving their profile or team settings. */ - @observable - isSaving = false; - - @observable - isFetching = true; - /* Whether the user is currently suspended. */ @observable isSuspended = false; @@ -99,12 +90,14 @@ export default class AuthStore { rootStore: RootStore; constructor(rootStore: RootStore) { + super(rootStore, Team); + this.rootStore = rootStore; // attempt to load the previous state of this store from localstorage const data: PersistedData = Storage.get(AUTH_STORE) || {}; this.rehydrate(data); - void this.fetch(); + void this.fetchAuth(); // persists this entire store to localstorage whenever any keys are changed autorun(() => { @@ -138,21 +131,44 @@ export default class AuthStore { @action rehydrate(data: PersistedData) { - this.user = data.user ? new User(data.user, this as any) : undefined; - this.team = data.team ? new Team(data.team, this as any) : undefined; + if (data.policies) { + this.addPolicies(data.policies); + } + if (data.team) { + this.add(data.team); + } + if (data.user) { + this.rootStore.users.add(data.user); + } + + this.currentUserId = data.user?.id; this.collaborationToken = data.collaborationToken; this.lastSignedIn = getCookie("lastSignedIn"); - this.addPolicies(data.policies); } - addPolicies(policies?: Policy[]) { - if (policies) { - // cache policies in this store so that they are persisted between sessions - this.policies = policies; - policies.forEach((policy) => this.rootStore.policies.add(policy)); - } + /** The current user */ + @computed + get user() { + return this.currentUserId + ? this.rootStore.users.get(this.currentUserId) + : undefined; + } + + /** The current team */ + @computed + get team() { + return this.orderedData.at(0); } + /** The current team's policies */ + @computed + get policies() { + return this.currentTeamId + ? [this.rootStore.policies.get(this.currentTeamId)] + : []; + } + + /** Whether the user is signed in */ @computed get authenticated(): boolean { return !!this.user && !!this.team; @@ -177,7 +193,7 @@ export default class AuthStore { }; @action - fetch = async () => { + fetchAuth = async () => { this.isFetching = true; try { @@ -185,21 +201,23 @@ export default class AuthStore { credentials: "same-origin", }); invariant(res?.data, "Auth not available"); - runInAction("AuthStore#fetch", () => { + + runInAction("AuthStore#refresh", () => { + const { data } = res; this.addPolicies(res.policies); - const { user, team } = res.data; - this.user = new User(user, this as any); - this.team = new Team(team, this as any); + this.add(data.team); + this.rootStore.users.add(data.user); + this.currentUserId = data.user.id; + this.currentTeamId = data.team.id; + this.availableTeams = res.data.availableTeams; this.collaborationToken = res.data.collaborationToken; if (env.SENTRY_DSN) { Sentry.configureScope(function (scope) { - scope.setUser({ - id: user.id, - }); - scope.setExtra("team", team.name); - scope.setExtra("teamId", team.id); + scope.setUser({ id: this.currentUserId }); + scope.setExtra("team", this.team.name); + scope.setExtra("teamId", this.team.id); }); } @@ -207,16 +225,16 @@ export default class AuthStore { // Occurs when the (sub)domain is changed in admin and the user hits an old url const { hostname, pathname } = window.location; - if (this.team.domain) { - if (this.team.domain !== hostname) { - window.location.href = `${team.url}${pathname}`; + if (data.team.domain) { + if (data.team.domain !== hostname) { + window.location.href = `${data.team.url}${pathname}`; return; } } else if ( isCloudHosted && - parseDomain(hostname).teamSubdomain !== (team.subdomain ?? "") + parseDomain(hostname).teamSubdomain !== (data.team.subdomain ?? "") ) { - window.location.href = `${team.url}${pathname}`; + window.location.href = `${data.team.url}${pathname}`; return; } @@ -250,79 +268,28 @@ export default class AuthStore { deleteUser = async (data: { code: string }) => { await client.post(`/users.delete`, data); runInAction("AuthStore#deleteUser", () => { - this.user = null; - this.team = null; + this.currentUserId = null; + this.currentTeamId = null; this.collaborationToken = null; this.availableTeams = this.availableTeams?.filter( (team) => team.id !== this.team?.id ); - this.policies = []; }); }; @action deleteTeam = async (data: { code: string }) => { await client.post(`/teams.delete`, data); + runInAction("AuthStore#deleteTeam", () => { - this.user = null; + this.currentUserId = null; + this.currentTeamId = null; this.availableTeams = this.availableTeams?.filter( (team) => team.id !== this.team?.id ); - this.policies = []; }); }; - @action - updateUser = async (params: { - name?: string; - avatarUrl?: string | null; - language?: string; - preferences?: UserPreferences; - }) => { - this.isSaving = true; - const previousData = this.user?.toAPI(); - - try { - this.user?.updateData(params); - const res = await client.post(`/users.update`, params); - invariant(res?.data, "User response not available"); - this.user?.updateData(res.data); - this.addPolicies(res.policies); - } catch (err) { - this.user?.updateData(previousData); - throw err; - } finally { - this.isSaving = false; - } - }; - - @action - updateTeam = async (params: { - name?: string; - avatarUrl?: string | null | undefined; - sharing?: boolean; - defaultCollectionId?: string | null; - subdomain?: string | null | undefined; - allowedDomains?: string[] | null | undefined; - preferences?: TeamPreferences; - }) => { - this.isSaving = true; - const previousData = this.team?.toAPI(); - - try { - this.team?.updateData(params); - const res = await client.post(`/team.update`, params); - invariant(res?.data, "Team response not available"); - this.team?.updateData(res.data); - this.addPolicies(res.policies); - } catch (err) { - this.team?.updateData(previousData); - throw err; - } finally { - this.isSaving = false; - } - }; - @action createTeam = async (params: { name: string }) => { this.isSaving = true; @@ -378,10 +345,9 @@ export default class AuthStore { } // clear all credentials from cache (and local storage via autorun) - this.user = null; - this.team = null; + this.currentUserId = null; + this.currentTeamId = null; this.collaborationToken = null; - this.policies = []; // Tell the host application we logged out, if any – allows window cleanup. void Desktop.bridge?.onLogout?.(); diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts index 27fa44ace0b0..df9fd1331111 100644 --- a/app/stores/RootStore.ts +++ b/app/stores/RootStore.ts @@ -56,11 +56,8 @@ export default class RootStore { webhookSubscriptions: WebhookSubscriptionsStore; constructor() { - // PoliciesStore must be initialized before AuthStore - this.policies = new PoliciesStore(this); this.apiKeys = new ApiKeysStore(this); this.authenticationProviders = new AuthenticationProvidersStore(this); - this.auth = new AuthStore(this); this.collections = new CollectionsStore(this); this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this); this.comments = new CommentsStore(this); @@ -73,6 +70,7 @@ export default class RootStore { this.memberships = new MembershipsStore(this); this.notifications = new NotificationsStore(this); this.pins = new PinsStore(this); + this.policies = new PoliciesStore(this); this.presence = new DocumentPresenceStore(); this.revisions = new RevisionsStore(this); this.searches = new SearchesStore(this); @@ -84,6 +82,9 @@ export default class RootStore { this.views = new ViewsStore(this); this.fileOperations = new FileOperationsStore(this); this.webhookSubscriptions = new WebhookSubscriptionsStore(this); + + // AuthStore must be initialized last as it makes use of the other stores. + this.auth = new AuthStore(this); } logout() { diff --git a/server/routes/api/teams/teams.ts b/server/routes/api/teams/teams.ts index 782fee94f282..bf10c117b9d2 100644 --- a/server/routes/api/teams/teams.ts +++ b/server/routes/api/teams/teams.ts @@ -21,34 +21,46 @@ import * as T from "./schema"; const router = new Router(); const emailEnabled = !!(env.SMTP_HOST || env.ENVIRONMENT === "development"); +const handleTeamUpdate = async (ctx: APIContext) => { + const { transaction } = ctx.state; + const { user } = ctx.state.auth; + const team = await Team.findByPk(user.teamId, { + include: [{ model: TeamDomain, separate: true }], + lock: transaction.LOCK.UPDATE, + transaction, + }); + authorize(user, "update", team); + + const updatedTeam = await teamUpdater({ + params: ctx.input.body, + user, + team, + ip: ctx.request.ip, + transaction, + }); + + ctx.body = { + data: presentTeam(updatedTeam), + policies: presentPolicies(user, [updatedTeam]), + }; +}; + router.post( "team.update", rateLimiter(RateLimiterStrategy.TenPerHour), auth(), validate(T.TeamsUpdateSchema), transaction(), - async (ctx: APIContext) => { - const { transaction } = ctx.state; - const { user } = ctx.state.auth; - const team = await Team.findByPk(user.teamId, { - include: [{ model: TeamDomain }], - transaction, - }); - authorize(user, "update", team); - - const updatedTeam = await teamUpdater({ - params: ctx.input.body, - user, - team, - ip: ctx.request.ip, - transaction, - }); + handleTeamUpdate +); - ctx.body = { - data: presentTeam(updatedTeam), - policies: presentPolicies(user, [updatedTeam]), - }; - } +router.post( + "teams.update", + rateLimiter(RateLimiterStrategy.TenPerHour), + auth(), + validate(T.TeamsUpdateSchema), + transaction(), + handleTeamUpdate ); router.post( From 92ba095124d5758654c3f02562019376d1d24e60 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 12:45:02 -0400 Subject: [PATCH 015/241] fix: Guard against empty items in Facepile users, closes #6087 --- app/components/Facepile.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/components/Facepile.tsx b/app/components/Facepile.tsx index a9de5116e8f0..bd9097354bf2 100644 --- a/app/components/Facepile.tsx +++ b/app/components/Facepile.tsx @@ -32,9 +32,12 @@ function Facepile({ )} - {users.slice(0, limit).map((user) => ( - {renderAvatar(user)} - ))} + {users + .filter(Boolean) + .slice(0, limit) + .map((user) => ( + {renderAvatar(user)} + ))} ); } From f2df25d115cfac1518537bd9ca189e060482edb8 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 13:03:38 -0400 Subject: [PATCH 016/241] Persist full-width as user preference when toggled closes #5562 --- app/menus/DocumentMenu.tsx | 9 ++++++++- app/scenes/DocumentNew.tsx | 5 ++++- shared/types.ts | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index 479a48eb50a8..8100e6e20bf1 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { s, ellipsis } from "@shared/styles"; +import { UserPreference } from "@shared/types"; import { getEventFiles } from "@shared/utils/files"; import Document from "~/models/Document"; import ContextMenu from "~/components/ContextMenu"; @@ -324,7 +325,13 @@ function DocumentMenu({ label={t("Full width")} checked={document.fullWidth} onChange={(ev) => { - document.fullWidth = ev.currentTarget.checked; + const fullWidth = ev.currentTarget.checked; + user.setPreference( + UserPreference.FullWidthDocuments, + fullWidth + ); + void user.save(); + document.fullWidth = fullWidth; void document.save(); }} /> diff --git a/app/scenes/DocumentNew.tsx b/app/scenes/DocumentNew.tsx index aadd2a1fe812..7fe618cff20d 100644 --- a/app/scenes/DocumentNew.tsx +++ b/app/scenes/DocumentNew.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useHistory, useLocation, useRouteMatch } from "react-router-dom"; import { toast } from "sonner"; +import { UserPreference } from "@shared/types"; import CenteredContent from "~/components/CenteredContent"; import Flex from "~/components/Flex"; import PlaceholderDocument from "~/components/PlaceholderDocument"; @@ -42,7 +43,9 @@ function DocumentNew({ template }: Props) { const document = await documents.create({ collectionId: collection?.id, parentDocumentId, - fullWidth: parentDocument?.fullWidth, + fullWidth: + parentDocument?.fullWidth || + user.getPreference(UserPreference.FullWidthDocuments), templateId: query.get("templateId") ?? undefined, template, title: "", diff --git a/shared/types.ts b/shared/types.ts index 3802e52ab0c6..5f72a9635f23 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -116,6 +116,8 @@ export enum UserPreference { CodeBlockLineNumers = "codeBlockLineNumbers", /** Whether documents have a separate edit mode instead of always editing. */ SeamlessEdit = "seamlessEdit", + /** Whether documents should start in full-width mode. */ + FullWidthDocuments = "fullWidthDocuments", } export type UserPreferences = { [key in UserPreference]?: boolean }; From 884f3c58968dafdec86a48cfbcb1edbaa425d672 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 13:12:30 -0400 Subject: [PATCH 017/241] fix: Emoji position when document is full-width --- .../Document/components/DocumentTitle.tsx | 37 +++++++++++++++---- app/scenes/Document/components/Editor.tsx | 1 + .../Document/components/RevisionViewer.tsx | 1 + 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/scenes/Document/components/DocumentTitle.tsx b/app/scenes/Document/components/DocumentTitle.tsx index d0375155c7e5..8d7f4f80779f 100644 --- a/app/scenes/Document/components/DocumentTitle.tsx +++ b/app/scenes/Document/components/DocumentTitle.tsx @@ -4,7 +4,7 @@ import { Selection } from "prosemirror-state"; import { __parseFromClipboard } from "prosemirror-view"; import * as React from "react"; import { mergeRefs } from "react-merge-refs"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import isMarkdown from "@shared/editor/lib/isMarkdown"; import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize"; @@ -33,6 +33,8 @@ type Props = { title: string; /** Emoji to display */ emoji?: string | null; + /** Position of the emoji relative to text */ + emojiPosition: "side" | "top"; /** Placeholder to display when the document has no title */ placeholder?: string; /** Should the title be editable, policies will also be considered separately */ @@ -57,6 +59,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle( documentId, title, emoji, + emojiPosition, readOnly, onChangeTitle, onChangeEmoji, @@ -254,7 +257,12 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle( ref={mergeRefs([ref, externalRef])} > {can.update && !readOnly ? ( - + ) : emoji ? ( - + {emojiIcon} ) : null} @@ -279,12 +292,22 @@ const StyledEmojiPicker = styled(EmojiPicker)` ${extraArea(8)} `; -const EmojiWrapper = styled(Flex)<{ dir?: string }>` - position: absolute; - top: 8px; - ${(props) => (props.dir === "rtl" ? "right: -40px" : "left: -40px")}; +const EmojiWrapper = styled(Flex)<{ $position: "top" | "side"; dir?: string }>` height: 32px; width: 32px; + + ${(props) => + props.$position === "top" + ? css` + position: relative; + top: -8px; + ` + : css` + position: absolute; + top: 8px; + ${(props: { dir?: string }) => + props.dir === "rtl" ? "right: -40px" : "left: -40px"}; + `} `; type TitleProps = { diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index aac018f081f2..5bf069d5963b 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -164,6 +164,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { : document.title } emoji={document.emoji} + emojiPosition={document.fullWidth ? "top" : "side"} onChangeTitle={onChangeTitle} onChangeEmoji={onChangeEmoji} onGoToNextInput={handleGoToNextInput} diff --git a/app/scenes/Document/components/RevisionViewer.tsx b/app/scenes/Document/components/RevisionViewer.tsx index 549e3c1ca3f9..424a30b61791 100644 --- a/app/scenes/Document/components/RevisionViewer.tsx +++ b/app/scenes/Document/components/RevisionViewer.tsx @@ -31,6 +31,7 @@ function RevisionViewer(props: Props) { documentId={revision.documentId} title={revision.title} emoji={revision.emoji} + emojiPosition={document.fullWidth ? "top" : "side"} readOnly /> Date: Sat, 28 Oct 2023 13:40:47 -0400 Subject: [PATCH 018/241] fix: Misalignment of code block line numbers when font-size is increased in Edge closes #5612 --- shared/editor/components/Styles.ts | 2 +- shared/editor/extensions/Prism.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index a1f81889213d..aa2582a26544 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -1149,7 +1149,7 @@ mark { &:after { content: attr(data-line-numbers); position: absolute; - padding-left: 1em; + padding-left: 0.5em; left: 1px; top: calc(1px + 0.75em); width: calc(var(--line-number-gutter-width,0) * 1em + .25em); diff --git a/shared/editor/extensions/Prism.ts b/shared/editor/extensions/Prism.ts index 6fcab0155fe8..2e56c97ea92b 100644 --- a/shared/editor/extensions/Prism.ts +++ b/shared/editor/extensions/Prism.ts @@ -121,14 +121,14 @@ function getDecorations({ const lineCountText = new Array(lineCount) .fill(0) .map((_, i) => padStart(`${i + 1}`, gutterWidth, " ")) - .join(" \n"); + .join("\n"); lineDecorations.push( Decoration.node( block.pos, block.pos + block.node.nodeSize, { - "data-line-numbers": `${lineCountText} `, + "data-line-numbers": `${lineCountText}`, style: `--line-number-gutter-width: ${gutterWidth};`, }, { From 89f3d473272dc839decc242e9dc18b63cdfa0f8c Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 19:09:53 -0400 Subject: [PATCH 019/241] Port HTML import improvements from enterprise codebase --- server/utils/turndown/emptyParagraph.ts | 1 + server/utils/turndown/images.ts | 34 ++++++++++++++--- server/utils/turndown/index.ts | 14 +++---- server/utils/turndown/sanitizeLists.ts | 51 +++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 server/utils/turndown/sanitizeLists.ts diff --git a/server/utils/turndown/emptyParagraph.ts b/server/utils/turndown/emptyParagraph.ts index 1b2017876df5..449f6481410c 100644 --- a/server/utils/turndown/emptyParagraph.ts +++ b/server/utils/turndown/emptyParagraph.ts @@ -11,6 +11,7 @@ export default function emptyParagraphs(turndownService: TurndownService) { return ( node.nodeName === "P" && node.children.length === 1 && + node.textContent?.trim() === "" && node.children[0].nodeName === "BR" ); }, diff --git a/server/utils/turndown/images.ts b/server/utils/turndown/images.ts index 3676d03d75c1..b59b99eed069 100644 --- a/server/utils/turndown/images.ts +++ b/server/utils/turndown/images.ts @@ -7,20 +7,44 @@ import TurndownService from "turndown"; */ export default function images(turndownService: TurndownService) { turndownService.addRule("image", { - filter: "img", + filter(node) { + return node.nodeName === "IMG" && !node?.className.includes("emoticon"); + }, replacement(content, node) { if (!("className" in node)) { return content; } const alt = cleanAttribute(node.getAttribute("alt") || ""); - const src = (node.getAttribute("src") || "").replace(/\n+/g, ""); + const src = cleanAttribute(node.getAttribute("src") || ""); const title = cleanAttribute(node.getAttribute("title") || ""); - const titlePart = title ? ' "' + title + '"' : ""; - return src ? "![" + alt + "]" + "(" + src + titlePart + ")" : ""; + + // Remove icons in issue keys as they will not resolve correctly and mess + // up the layout. + if ( + node.className === "icon" && + node.parentElement?.className.includes("jira-issue-key") + ) { + return ""; + } + + // Respect embedded Confluence image size + let size; + const naturalWidth = node.getAttribute("data-width"); + const naturalHeight = node.getAttribute("data-height"); + const width = node.getAttribute("width"); + + if (naturalWidth && naturalHeight && width) { + const ratio = parseInt(naturalWidth) / parseInt(width); + size = ` =${width}x${parseInt(naturalHeight) / ratio}`; + } + + const titlePart = title || size ? ` "${title}${size}"` : ""; + + return src ? `![${alt}](${src}${titlePart})` : ""; }, }); } function cleanAttribute(attribute: string) { - return attribute ? attribute.replace(/(\n+\s*)+/g, "\n") : ""; + return (attribute ? attribute.replace(/\n+/g, "") : "").trim(); } diff --git a/server/utils/turndown/index.ts b/server/utils/turndown/index.ts index 4d359636804c..120024c6f8fb 100644 --- a/server/utils/turndown/index.ts +++ b/server/utils/turndown/index.ts @@ -2,9 +2,10 @@ import { gfm } from "@joplin/turndown-plugin-gfm"; import TurndownService from "turndown"; import breaks from "./breaks"; import emptyLists from "./emptyLists"; -import emptyParagraphs from "./emptyParagraph"; +import emptyParagraph from "./emptyParagraph"; import frames from "./frames"; import images from "./images"; +import sanitizeLists from "./sanitizeLists"; import sanitizeTables from "./sanitizeTables"; import underlines from "./underlines"; @@ -18,17 +19,14 @@ const service = new TurndownService({ bulletListMarker: "-", headingStyle: "atx", codeBlockStyle: "fenced", - blankReplacement: (content, node) => { - if (node.nodeName === "P") { - return "\n\n\\\n"; - } - return ""; - }, + blankReplacement: (content, node) => + node.nodeName === "P" ? "\n\n\\\n" : "", }) .remove(["script", "style", "title", "head"]) .use(gfm) - .use(emptyParagraphs) + .use(emptyParagraph) .use(sanitizeTables) + .use(sanitizeLists) .use(underlines) .use(frames) .use(images) diff --git a/server/utils/turndown/sanitizeLists.ts b/server/utils/turndown/sanitizeLists.ts new file mode 100644 index 000000000000..d9dcc43f850c --- /dev/null +++ b/server/utils/turndown/sanitizeLists.ts @@ -0,0 +1,51 @@ +import TurndownService from "turndown"; + +/** + * A turndown plugin for removing incompatible nodes from lists. + * + * @param turndownService The TurndownService instance. + */ +export default function sanitizeLists(turndownService: TurndownService) { + function inHtmlContext(node: HTMLElement, selector: string) { + let currentNode = node; + // start at the closest element + while (currentNode !== null && currentNode.nodeType !== 1) { + currentNode = (currentNode.parentElement || + currentNode.parentNode) as HTMLElement; + } + return ( + currentNode !== null && + currentNode.nodeType === 1 && + currentNode.closest(selector) !== null + ); + } + + turndownService.addRule("headingsInLists", { + filter(node) { + return ( + ["H1", "H2", "H3", "H4", "H5", "H6"].includes(node.nodeName) && + inHtmlContext(node, "LI") + ); + }, + replacement(content, node, options) { + if (!content.trim()) { + return ""; + } + return options.strongDelimiter + content + options.strongDelimiter; + }, + }); + + turndownService.addRule("strongInHeadings", { + filter(node) { + return ( + (node.nodeName === "STRONG" || node.nodeName === "B") && + ["H1", "H2", "H3", "H4", "H5", "H6"].some((tag) => + inHtmlContext(node, tag) + ) + ); + }, + replacement(content) { + return content; + }, + }); +} From e6196ae79e5be03205fb2c281473295571167fc1 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 19:51:13 -0400 Subject: [PATCH 020/241] fix: Find and replace dialog should be fixed when scrolling --- app/components/Popover.tsx | 9 ++++++--- app/editor/components/FindAndReplace.tsx | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/components/Popover.tsx b/app/components/Popover.tsx index eaa784c3cc01..268d75fecaff 100644 --- a/app/components/Popover.tsx +++ b/app/components/Popover.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { Dialog } from "reakit/Dialog"; import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover"; -import styled, { css } from "styled-components"; +import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { depths, s } from "@shared/styles"; import useKeyDown from "~/hooks/useKeyDown"; @@ -95,10 +95,13 @@ const Contents = styled.div` width: ${(props) => props.$width}px; ${(props) => - props.$scrollable && - css` + props.$scrollable + ? ` overflow-x: hidden; overflow-y: auto; + ` + : ` + overflow: hidden; `} ${breakpoint("mobile", "tablet")` diff --git a/app/editor/components/FindAndReplace.tsx b/app/editor/components/FindAndReplace.tsx index 0f5e9653bebd..a3ed0861f900 100644 --- a/app/editor/components/FindAndReplace.tsx +++ b/app/editor/components/FindAndReplace.tsx @@ -198,7 +198,7 @@ export default function FindAndReplace({ readOnly }: Props) { const style: React.CSSProperties = React.useMemo( () => ({ - position: "absolute", + position: "fixed", left: "initial", top: 60, right: 16, @@ -263,6 +263,7 @@ export default function FindAndReplace({ readOnly }: Props) { unstable_finalFocusRef={finalFocusRef} style={style} aria-label={t("Find and replace")} + scrollable={false} width={420} > @@ -365,4 +366,5 @@ const ButtonLarge = styled(ButtonSmall)` const Content = styled(Flex)` padding: 8px 0; margin-bottom: -16px; + position: static; `; From 7b98ce3514d5d583a4c104d5b333ee3d48ce35b6 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 20:42:57 -0400 Subject: [PATCH 021/241] fix: Background transition on home screen --- app/scenes/Home.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/scenes/Home.tsx b/app/scenes/Home.tsx index 5a09d0b7d0c9..4110548483e9 100644 --- a/app/scenes/Home.tsx +++ b/app/scenes/Home.tsx @@ -107,6 +107,7 @@ function Home() { const Documents = styled.div` position: relative; background: ${s("background")}; + transition: ${s("backgroundTransition")}; `; export default observer(Home); From 4d3655bc6cd742c48c4e7efa4bbd74d317579214 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 22:22:10 -0400 Subject: [PATCH 022/241] Remove usage of .at() for browser compat --- app/stores/AuthStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index e7995819f49a..aaf472cc0b7b 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -157,7 +157,7 @@ export default class AuthStore extends Store { /** The current team */ @computed get team() { - return this.orderedData.at(0); + return this.orderedData[0]; } /** The current team's policies */ From 3fd429baa9c1d3db57852243aa39f6639d604610 Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Sun, 29 Oct 2023 20:01:47 +0530 Subject: [PATCH 023/241] `usePaginatedRequest` hook for simpler handling of pagination on FE (#6060) * feat: usePaginatedRequest hook * fix: spread params * fix: handle limit zero * fix: handle case when stars.fetchPage returns empty array * fix: use stars.orderedData for reactivity --- app/components/Sidebar/components/Starred.tsx | 50 ++++------ app/hooks/usePaginatedRequest.ts | 91 +++++++++++++++++++ shared/i18n/locales/en_US/translation.json | 1 + 3 files changed, 111 insertions(+), 31 deletions(-) create mode 100644 app/hooks/usePaginatedRequest.ts diff --git a/app/components/Sidebar/components/Starred.tsx b/app/components/Sidebar/components/Starred.tsx index f4c230fead44..6ae3870dfa9d 100644 --- a/app/components/Sidebar/components/Starred.tsx +++ b/app/components/Sidebar/components/Starred.tsx @@ -3,9 +3,11 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import Star from "~/models/Star"; import DelayedMount from "~/components/DelayedMount"; import Flex from "~/components/Flex"; +import usePaginatedRequest from "~/hooks/usePaginatedRequest"; import useStores from "~/hooks/useStores"; import DropCursor from "./DropCursor"; import Header from "./Header"; @@ -18,36 +20,16 @@ import StarredLink from "./StarredLink"; const STARRED_PAGINATION_LIMIT = 10; function Starred() { - const [fetchError, setFetchError] = React.useState(); - const [displayedStarsCount, setDisplayedStarsCount] = React.useState( - STARRED_PAGINATION_LIMIT - ); const { stars } = useStores(); const { t } = useTranslation(); - const fetchResults = React.useCallback( - async (offset = 0) => { - try { - await stars.fetchPage({ - limit: STARRED_PAGINATION_LIMIT + 1, - offset, - }); - } catch (error) { - setFetchError(error); - } - }, - [stars] + const { loading, next, end, error, page } = usePaginatedRequest( + stars.fetchPage, + { + limit: STARRED_PAGINATION_LIMIT, + } ); - React.useEffect(() => { - void fetchResults(); - }, []); - - const handleShowMore = async () => { - await fetchResults(displayedStarsCount); - setDisplayedStarsCount((prev) => prev + STARRED_PAGINATION_LIMIT); - }; - // Drop to reorder document const [{ isOverReorder, isDraggingAnyStar }, dropToReorder] = useDrop({ accept: "star", @@ -62,6 +44,10 @@ function Starred() { }), }); + if (error) { + toast.error(t("Could not load starred documents")); + } + if (!stars.orderedData.length) { return null; } @@ -78,18 +64,20 @@ function Starred() { position="top" /> )} - {stars.orderedData.slice(0, displayedStarsCount).map((star) => ( - - ))} - {stars.orderedData.length > displayedStarsCount && ( + {stars.orderedData + .slice(0, page * STARRED_PAGINATION_LIMIT) + .map((star) => ( + + ))} + {!end && ( )} - {(stars.isFetching || fetchError) && !stars.orderedData.length && ( + {loading && ( diff --git a/app/hooks/usePaginatedRequest.ts b/app/hooks/usePaginatedRequest.ts new file mode 100644 index 000000000000..c2e930d98ce7 --- /dev/null +++ b/app/hooks/usePaginatedRequest.ts @@ -0,0 +1,91 @@ +import uniqBy from "lodash/uniqBy"; +import * as React from "react"; +import { PaginationParams } from "~/types"; +import useRequest from "./useRequest"; + +type RequestResponse = { + /** The return value of the paginated request function. */ + data: T[] | undefined; + /** The request error, if any. */ + error: unknown; + /** Whether the request is currently in progress. */ + loading: boolean; + /** Function to trigger next page request. */ + next: () => void; + /** Page number */ + page: number; + /** Marks the end of pagination */ + end: boolean; +}; + +const INITIAL_OFFSET = 0; +const DEFAULT_LIMIT = 10; + +/** + * A hook to make paginated API request and track its state within a component. + * + * @param requestFn The function to call to make the request, it should return a promise. + * @param params Pagination params(limit, offset etc) to be passed to requestFn. + * @returns + */ +export default function usePaginatedRequest( + requestFn: (params?: PaginationParams | undefined) => Promise, + params: PaginationParams +): RequestResponse { + const [data, setData] = React.useState(); + const [offset, setOffset] = React.useState(INITIAL_OFFSET); + const [page, setPage] = React.useState(0); + const [end, setEnd] = React.useState(false); + const displayLimit = params.limit || DEFAULT_LIMIT; + const fetchLimit = displayLimit + 1; + const [paginatedReq, setPaginatedReq] = React.useState( + () => () => + requestFn({ + ...params, + offset: 0, + limit: fetchLimit, + }) + ); + + const { + data: response, + error, + loading, + request, + } = useRequest(paginatedReq); + + React.useEffect(() => { + void request(); + }, [request]); + + React.useEffect(() => { + if (response && !loading) { + setData((prev) => + uniqBy((prev ?? []).concat(response.slice(0, displayLimit)), "id") + ); + setPage((prev) => prev + 1); + if (response.length <= displayLimit) { + setEnd(true); + } + } + }, [response, displayLimit, loading]); + + React.useEffect(() => { + if (offset) { + setPaginatedReq( + () => () => + requestFn({ + ...params, + offset, + limit: fetchLimit, + }) + ); + } + }, [offset, fetchLimit, requestFn]); + + const next = React.useCallback(() => { + setOffset((prev) => prev + displayLimit); + }, [displayLimit]); + + return { data, next, loading, error, page, end }; +} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index a95707afb303..d334daa9b046 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -243,6 +243,7 @@ "Empty": "Empty", "Go back": "Go back", "Go forward": "Go forward", + "Could not load starred documents": "Could not load starred documents", "Starred": "Starred", "Show more": "Show more", "Up to date": "Up to date", From 90bc60d4cfc5dd360bda1044c762c0bed4aca9c3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 29 Oct 2023 12:12:23 -0400 Subject: [PATCH 024/241] Move pinned collection documents above description --- app/components/CollectionDescription.tsx | 2 +- app/scenes/Collection.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/CollectionDescription.tsx b/app/components/CollectionDescription.tsx index d0d0e2260507..37f953fe4ba9 100644 --- a/app/components/CollectionDescription.tsx +++ b/app/components/CollectionDescription.tsx @@ -165,7 +165,7 @@ const MaxHeight = styled.div` position: relative; max-height: 25vh; overflow: hidden; - margin: -12px -8px -8px; + margin: 8px -8px -8px; padding: 8px; &[data-editing="true"], diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection.tsx index d33e8ac30086..cec205498516 100644 --- a/app/scenes/Collection.tsx +++ b/app/scenes/Collection.tsx @@ -170,12 +170,12 @@ function CollectionScene() { )} - + From 6b13a322342f91ffa9a7af8f9b5c4d60d352bece Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 29 Oct 2023 18:31:12 -0400 Subject: [PATCH 025/241] fix: Refactor hover previews to reduce false positives (#6091) --- app/components/Editor.tsx | 13 +- app/components/HoverPreview/HoverPreview.tsx | 322 ++++++++++--------- app/editor/index.tsx | 2 +- app/hooks/usePrevious.ts | 12 +- app/hooks/useRequest.ts | 2 +- shared/editor/extensions/HoverPreviews.ts | 64 ++++ shared/editor/marks/Link.tsx | 16 +- shared/editor/nodes/Mention.ts | 30 +- shared/editor/nodes/index.ts | 2 + 9 files changed, 258 insertions(+), 205 deletions(-) create mode 100644 shared/editor/extensions/HoverPreviews.ts diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 4c0dafa585b8..c5f3e41bd475 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -77,10 +77,13 @@ function Editor(props: Props, ref: React.RefObject | null) { React.useState(null); const previousCommentIds = React.useRef(); - const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => { - setActiveLink(element); - return false; - }, []); + const handleLinkActive = React.useCallback( + (element: HTMLAnchorElement | null) => { + setActiveLink(element); + return false; + }, + [] + ); const handleLinkInactive = React.useCallback(() => { setActiveLink(null); @@ -351,7 +354,7 @@ function Editor(props: Props, ref: React.RefObject | null) { minHeight={props.editorStyle.paddingBottom} /> )} - {activeLinkElement && !shareId && ( + {!shareId && ( void; }; @@ -32,99 +34,23 @@ enum Direction { DOWN, } -const POINTER_HEIGHT = 22; -const POINTER_WIDTH = 22; - -function HoverPreviewInternal({ element, onClose }: Props) { - const url = element.href || element.dataset.url; +function HoverPreviewDesktop({ element, onClose }: Props) { + const url = element?.href || element?.dataset.url; + const previousUrl = usePrevious(url, true); const [isVisible, setVisible] = React.useState(false); const timerClose = React.useRef>(); - const timerOpen = React.useRef>(); const cardRef = React.useRef(null); - const stores = useStores(); - const [cardLeft, setCardLeft] = React.useState(0); - const [cardTop, setCardTop] = React.useState(0); - const [pointerLeft, setPointerLeft] = React.useState(0); - const [pointerTop, setPointerTop] = React.useState(0); - const [pointerDir, setPointerDir] = React.useState(Direction.UP); - - React.useLayoutEffect(() => { - if (isVisible && cardRef.current) { - const elem = element.getBoundingClientRect(); - const card = cardRef.current.getBoundingClientRect(); - - let cTop = elem.bottom + window.scrollY + CARD_MARGIN; - let pTop = -POINTER_HEIGHT; - let pDir = Direction.UP; - if (cTop + card.height > window.innerHeight + window.scrollY) { - // shift card upwards if it goes out of screen - const bottom = elem.top + window.scrollY; - cTop = bottom - card.height; - // shift a little further to leave some margin between card and element boundary - cTop -= CARD_MARGIN; - // pointer should be shifted downwards to align with card's bottom - pTop = card.height; - pDir = Direction.DOWN; - } - setCardTop(cTop); - setPointerTop(pTop); - setPointerDir(pDir); - - let cLeft = elem.left; - let pLeft = elem.width / 2; - if (cLeft + card.width > window.innerWidth) { - // shift card leftwards by the amount it went out of screen - let shiftBy = cLeft + card.width - window.innerWidth; - // shift a little further to leave some margin between card and window boundary - shiftBy += CARD_MARGIN; - cLeft -= shiftBy; - - // shift pointer rightwards by same amount so as to position it back correctly - pLeft += shiftBy; - } - setCardLeft(cLeft); - setPointerLeft(pLeft); - } - }, [isVisible, element]); - - const { data, request, loading } = useRequest( - React.useCallback( - () => - client.post("/urls.unfurl", { - url, - documentId: stores.ui.activeDocumentId, - }), - [url, stores.ui.activeDocumentId] - ) - ); - - React.useEffect(() => { - if (url) { - stopOpenTimer(); - setVisible(false); - - void request(); - } - }, [url, request]); - - const stopOpenTimer = () => { - if (timerOpen.current) { - clearTimeout(timerOpen.current); - timerOpen.current = undefined; - } - }; + const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } = + useHoverPosition({ + cardRef, + element, + isVisible, + }); const closePreview = React.useCallback(() => { - if (isVisible) { - stopOpenTimer(); - setVisible(false); - onClose(); - } - }, [isVisible, onClose]); - - useOnClickOutside(cardRef, closePreview); - useKeyDown("Escape", closePreview); - useEventListener("scroll", closePreview, window, { capture: true }); + setVisible(false); + onClose(); + }, [onClose]); const stopCloseTimer = React.useCallback(() => { if (timerClose.current) { @@ -133,38 +59,36 @@ function HoverPreviewInternal({ element, onClose }: Props) { } }, []); - const startOpenTimer = React.useCallback(() => { - if (!timerOpen.current) { - timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN); - } - }, []); - const startCloseTimer = React.useCallback(() => { - stopOpenTimer(); timerClose.current = setTimeout(closePreview, DELAY_CLOSE); }, [closePreview]); + // Open and close the preview when the element changes. React.useEffect(() => { - const card = cardRef.current; + if (element) { + setVisible(true); + } else { + startCloseTimer(); + } + }, [startCloseTimer, element]); - if (data) { - startOpenTimer(); + // Close the preview on Escape, scroll, or click outside. + useOnClickOutside(cardRef, closePreview); + useKeyDown("Escape", closePreview); + useEventListener("scroll", closePreview, window, { capture: true }); + // Ensure that the preview stays open while the user is hovering over the card. + React.useEffect(() => { + const card = cardRef.current; + + if (isVisible) { if (card) { card.addEventListener("mouseenter", stopCloseTimer); card.addEventListener("mouseleave", startCloseTimer); } - - element.addEventListener("mouseout", startCloseTimer); - element.addEventListener("mouseover", stopCloseTimer); - element.addEventListener("mouseover", startOpenTimer); } return () => { - element.removeEventListener("mouseout", startCloseTimer); - element.removeEventListener("mouseover", stopCloseTimer); - element.removeEventListener("mouseover", startOpenTimer); - if (card) { card.removeEventListener("mouseenter", stopCloseTimer); card.removeEventListener("mouseleave", startCloseTimer); @@ -172,69 +96,159 @@ function HoverPreviewInternal({ element, onClose }: Props) { stopCloseTimer(); }; - }, [element, startCloseTimer, data, startOpenTimer, stopCloseTimer]); + }, [element, startCloseTimer, isVisible, stopCloseTimer]); - if (loading) { - return ; - } + const displayUrl = url ?? previousUrl; - if (!data) { + if (!isVisible || !displayUrl) { return null; } return ( - - {isVisible ? ( - - {data.type === UnfurlType.Mention ? ( - - ) : data.type === UnfurlType.Document ? ( - - ) : ( - + + {(data) => ( + + {data.type === UnfurlType.Mention ? ( + + ) : data.type === UnfurlType.Document ? ( + + ) : ( + + )} + - )} - - - ) : null} + + )} + ); } +function DataLoader({ + url, + children, +}: { + url: string; + children: (data: any) => React.ReactNode; +}) { + const { ui } = useStores(); + const { data, request, loading } = useRequest( + React.useCallback( + () => + client.post("/urls.unfurl", { + url, + documentId: ui.activeDocumentId, + }), + [url, ui.activeDocumentId] + ) + ); + + React.useEffect(() => { + if (url) { + void request(); + } + }, [url, request]); + + if (loading) { + return ; + } + + if (!data) { + return null; + } + + return <>{children(data)}; +} + function HoverPreview({ element, ...rest }: Props) { const isMobile = useMobile(); if (isMobile) { return null; } - return ; + return ; +} + +function useHoverPosition({ + cardRef, + element, + isVisible, +}: { + cardRef: React.RefObject; + element: HTMLAnchorElement | null; + isVisible: boolean; +}) { + const [cardLeft, setCardLeft] = React.useState(0); + const [cardTop, setCardTop] = React.useState(0); + const [pointerLeft, setPointerLeft] = React.useState(0); + const [pointerTop, setPointerTop] = React.useState(0); + const [pointerDir, setPointerDir] = React.useState(Direction.UP); + + React.useLayoutEffect(() => { + if (isVisible && element && cardRef.current) { + const elem = element.getBoundingClientRect(); + const card = cardRef.current.getBoundingClientRect(); + + let cTop = elem.bottom + window.scrollY + CARD_MARGIN; + let pTop = -POINTER_HEIGHT; + let pDir = Direction.UP; + if (cTop + card.height > window.innerHeight + window.scrollY) { + // shift card upwards if it goes out of screen + const bottom = elem.top + window.scrollY; + cTop = bottom - card.height; + // shift a little further to leave some margin between card and element boundary + cTop -= CARD_MARGIN; + // pointer should be shifted downwards to align with card's bottom + pTop = card.height; + pDir = Direction.DOWN; + } + setCardTop(cTop); + setPointerTop(pTop); + setPointerDir(pDir); + + let cLeft = elem.left; + let pLeft = elem.width / 2; + if (cLeft + card.width > window.innerWidth) { + // shift card leftwards by the amount it went out of screen + let shiftBy = cLeft + card.width - window.innerWidth; + // shift a little further to leave some margin between card and window boundary + shiftBy += CARD_MARGIN; + cLeft -= shiftBy; + + // shift pointer rightwards by same amount so as to position it back correctly + pLeft += shiftBy; + } + setCardLeft(cLeft); + setPointerLeft(pLeft); + } + }, [isVisible, cardRef, element]); + + return { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir }; } const Animate = styled(m.div)` diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 08bc8dcca1b6..9bd1218a966c 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -124,7 +124,7 @@ export type Props = { event: MouseEvent | React.MouseEvent ) => void; /** Callback when user hovers on any link in the document */ - onHoverLink?: (element: HTMLAnchorElement) => boolean; + onHoverLink?: (element: HTMLAnchorElement | null) => boolean; /** Callback when user presses any key with document focused */ onKeyDown?: (event: React.KeyboardEvent) => void; /** Collection of embed types to render in the document */ diff --git a/app/hooks/usePrevious.ts b/app/hooks/usePrevious.ts index 4dbc6a474aa4..6e395ab3c37d 100644 --- a/app/hooks/usePrevious.ts +++ b/app/hooks/usePrevious.ts @@ -1,9 +1,19 @@ import * as React from "react"; -export default function usePrevious(value: T): T | void { +/** + * A hook to get the previous value of a variable. + * + * @param value The value to track. + * @param onlyTruthy Whether to include only truthy values. + * @returns The previous value of the variable. + */ +export default function usePrevious(value: T, onlyTruthy = false): T | void { const ref = React.useRef(); React.useEffect(() => { + if (onlyTruthy && !value) { + return; + } ref.current = value; }); diff --git a/app/hooks/useRequest.ts b/app/hooks/useRequest.ts index e8af4b1cdaca..603cd44932ff 100644 --- a/app/hooks/useRequest.ts +++ b/app/hooks/useRequest.ts @@ -16,7 +16,7 @@ type RequestResponse = { * A hook to make an API request and track its state within a component. * * @param requestFn The function to call to make the request, it should return a promise. - * @returns + * @returns An object containing the request state and a function to start the request. */ export default function useRequest( requestFn: () => Promise diff --git a/shared/editor/extensions/HoverPreviews.ts b/shared/editor/extensions/HoverPreviews.ts new file mode 100644 index 000000000000..c3ba34ed23b3 --- /dev/null +++ b/shared/editor/extensions/HoverPreviews.ts @@ -0,0 +1,64 @@ +import { Plugin } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import Extension from "../lib/Extension"; + +interface HoverPreviewsOptions { + /** Callback when a hover target is found or lost. */ + onHoverLink?: (target: Element | null) => void; + + /** Delay before the target is considered "hovered" and callback is triggered. */ + delay: number; +} + +export default class HoverPreviews extends Extension { + get defaultOptions(): HoverPreviewsOptions { + return { + delay: 500, + }; + } + + get name() { + return "hover-previews"; + } + + get plugins() { + const isHoverTarget = (target: Element | null, view: EditorView) => + target instanceof HTMLElement && + this.editor.elementRef.current?.contains(target) && + (!view.editable || (view.editable && !view.hasFocus())); + + let hoveringTimeout: ReturnType; + + return [ + new Plugin({ + props: { + handleDOMEvents: { + mouseover: (view: EditorView, event: MouseEvent) => { + const target = (event.target as HTMLElement)?.closest( + ".use-hover-preview" + ); + if (isHoverTarget(target, view)) { + if (this.options.onHoverLink) { + hoveringTimeout = setTimeout(() => { + this.options.onHoverLink?.(target); + }, this.options.delay); + } + } + return false; + }, + mouseout: (view: EditorView, event: MouseEvent) => { + const target = (event.target as HTMLElement)?.closest( + ".use-hover-preview" + ); + if (isHoverTarget(target, view)) { + clearTimeout(hoveringTimeout); + this.options.onHoverLink?.(null); + } + return false; + }, + }, + }, + }), + ]; + } +} diff --git a/shared/editor/marks/Link.tsx b/shared/editor/marks/Link.tsx index ecfdfdd0a3bf..59bbbfa9bf20 100644 --- a/shared/editor/marks/Link.tsx +++ b/shared/editor/marks/Link.tsx @@ -88,7 +88,7 @@ export default class Link extends Mark { { title: node.attrs.title, href: sanitizeUrl(node.attrs.href), - class: "text-link", + class: "use-hover-preview", rel: "noopener noreferrer nofollow", }, 0, @@ -203,20 +203,6 @@ export default class Link extends Mark { props: { decorations: (state: EditorState) => plugin.getState(state), handleDOMEvents: { - mouseover: (view: EditorView, event: MouseEvent) => { - const target = (event.target as HTMLElement)?.closest("a"); - if ( - target instanceof HTMLAnchorElement && - target.className.includes("text-link") && - this.editor.elementRef.current?.contains(target) && - (!view.editable || (view.editable && !view.hasFocus())) - ) { - if (this.options.onHoverLink) { - return this.options.onHoverLink(target); - } - } - return false; - }, mousedown: (view: EditorView, event: MouseEvent) => { const target = (event.target as HTMLElement)?.closest("a"); if (!(target instanceof HTMLAnchorElement) || event.button !== 0) { diff --git a/shared/editor/nodes/Mention.ts b/shared/editor/nodes/Mention.ts index 948c7783d728..18fef134f59b 100644 --- a/shared/editor/nodes/Mention.ts +++ b/shared/editor/nodes/Mention.ts @@ -5,8 +5,7 @@ import { NodeType, Schema, } from "prosemirror-model"; -import { Command, Plugin, TextSelection } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; +import { Command, TextSelection } from "prosemirror-state"; import { Primitive } from "utility-types"; import Suggestion from "../extensions/Suggestion"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; @@ -64,7 +63,7 @@ export default class Mention extends Suggestion { toDOM: (node) => [ "span", { - class: `${node.type.name}`, + class: `${node.type.name} use-hover-preview`, id: node.attrs.id, "data-type": node.attrs.type, "data-id": node.attrs.modelId, @@ -81,31 +80,6 @@ export default class Mention extends Suggestion { return [mentionRule]; } - get plugins(): Plugin[] { - return [ - new Plugin({ - props: { - handleDOMEvents: { - mouseover: (view: EditorView, event: MouseEvent) => { - const target = (event.target as HTMLElement)?.closest("span"); - if ( - target instanceof HTMLSpanElement && - this.editor.elementRef.current?.contains(target) && - target.className.includes("mention") && - (!view.editable || (view.editable && !view.hasFocus())) - ) { - if (this.options.onHoverLink) { - return this.options.onHoverLink(target); - } - } - return false; - }, - }, - }, - }), - ]; - } - commands({ type }: { type: NodeType; schema: Schema }) { return (attrs: Record): Command => (state, dispatch) => { diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 6ef1263b148e..917aa4deb45b 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -3,6 +3,7 @@ import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer"; import DateTime from "../extensions/DateTime"; import FindAndReplace from "../extensions/FindAndReplace"; import History from "../extensions/History"; +import HoverPreviews from "../extensions/HoverPreviews"; import Keys from "../extensions/Keys"; import MaxLength from "../extensions/MaxLength"; import PasteHandler from "../extensions/PasteHandler"; @@ -113,6 +114,7 @@ export const richExtensions: Nodes = [ MathBlock, PreventTab, FindAndReplace, + HoverPreviews, ]; /** From 9f6c1f8b678a0e5f0536eeb139e9f2b7c7dae522 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 29 Oct 2023 19:20:15 -0400 Subject: [PATCH 026/241] Hide scrollbars on search filters bar --- app/scenes/Search/Search.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx index addaae67096f..f8a6e95f3d03 100644 --- a/app/scenes/Search/Search.tsx +++ b/app/scenes/Search/Search.tsx @@ -9,6 +9,7 @@ import { Waypoint } from "react-waypoint"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { v4 as uuidv4 } from "uuid"; +import { hideScrollbars } from "@shared/styles"; import { DateFilter as TDateFilter } from "@shared/types"; import { SearchParams } from "~/stores/DocumentsStore"; import RootStore from "~/stores/RootStore"; @@ -467,6 +468,7 @@ const Filters = styled(Flex)` overflow-y: hidden; overflow-x: auto; padding: 8px 0; + ${hideScrollbars()} ${breakpoint("tablet")` padding: 0; From f0825b4cd9ef77705d4540f0f5a069d6ce7c0792 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 29 Oct 2023 20:27:06 -0400 Subject: [PATCH 027/241] fix: Remove broken comment ability on templates --- app/actions/definitions/documents.tsx | 3 +-- app/scenes/Document/components/DocumentMeta.tsx | 15 +++++---------- app/scenes/Document/components/Editor.tsx | 1 - 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 7c2f4fee7ab6..8632f1ad12cc 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -781,8 +781,7 @@ export const openDocumentComments = createAction({ const can = stores.policies.abilities(activeDocumentId ?? ""); return ( !!activeDocumentId && - can.read && - !can.restore && + can.comment && !!stores.auth.team?.getPreference(TeamPreference.Commenting) ); }, diff --git a/app/scenes/Document/components/DocumentMeta.tsx b/app/scenes/Document/components/DocumentMeta.tsx index d87ea0a606f0..bda1965d29f9 100644 --- a/app/scenes/Document/components/DocumentMeta.tsx +++ b/app/scenes/Document/components/DocumentMeta.tsx @@ -11,6 +11,7 @@ import Revision from "~/models/Revision"; import DocumentMeta from "~/components/DocumentMeta"; import Fade from "~/components/Fade"; import useCurrentTeam from "~/hooks/useCurrentTeam"; +import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { documentPath, documentInsightsPath } from "~/utils/routeHelpers"; @@ -18,18 +19,11 @@ type Props = { /* The document to display meta data for */ document: Document; revision?: Revision; - isDraft: boolean; to?: LocationDescriptor; rtl?: boolean; }; -function TitleDocumentMeta({ - to, - isDraft, - document, - revision, - ...rest -}: Props) { +function TitleDocumentMeta({ to, document, revision, ...rest }: Props) { const { views, comments, ui } = useStores(); const { t } = useTranslation(); const match = useRouteMatch(); @@ -38,6 +32,7 @@ function TitleDocumentMeta({ const totalViewers = documentViews.length; const onlyYou = totalViewers === 1 && documentViews[0].userId; const viewsLoadedOnMount = React.useRef(totalViewers > 0); + const can = usePolicy(document.id); const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade; @@ -46,7 +41,7 @@ function TitleDocumentMeta({ return ( - {team.getPreference(TeamPreference.Commenting) && ( + {team.getPreference(TeamPreference.Commenting) && can.comment && ( <>  •  )} - {totalViewers && !isDraft ? ( + {totalViewers && !document.isDraft && !document.isTemplate ? (  •  ) { /> {!shareId && ( Date: Sun, 29 Oct 2023 20:42:23 -0400 Subject: [PATCH 028/241] Add success message on import completion --- app/components/WebsocketProvider.tsx | 19 +++++++++++++++---- .../Settings/components/DropToImport.tsx | 8 +++++--- shared/i18n/locales/en_US/translation.json | 1 + 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index b47bc9e080f4..d5fd2ab05dfb 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -3,8 +3,10 @@ import find from "lodash/find"; import { action, observable } from "mobx"; import { observer } from "mobx-react"; import * as React from "react"; +import { withTranslation, WithTranslation } from "react-i18next"; import { io, Socket } from "socket.io-client"; import { toast } from "sonner"; +import { FileOperationState } from "@shared/types"; import RootStore from "~/stores/RootStore"; import Collection from "~/models/Collection"; import Comment from "~/models/Comment"; @@ -34,7 +36,7 @@ type SocketWithAuthentication = Socket & { export const WebsocketContext = React.createContext(null); -type Props = RootStore; +type Props = WithTranslation & RootStore; @observer class WebsocketProvider extends React.Component { @@ -431,6 +433,15 @@ class WebsocketProvider extends React.Component { "fileOperations.update", (event: PartialWithId) => { fileOperations.add(event); + + if ( + event.state === FileOperationState.Complete && + event.user?.id === auth.user?.id + ) { + toast.success(event.name, { + description: this.props.t("Your import completed"), + }); + } } ); @@ -450,13 +461,13 @@ class WebsocketProvider extends React.Component { // received a message from the API server that we should request // to join a specific room. Forward that to the ws server. - this.socket.on("join", (event: any) => { + this.socket.on("join", (event) => { this.socket?.emit("join", event); }); // received a message from the API server that we should request // to leave a specific room. Forward that to the ws server. - this.socket.on("leave", (event: any) => { + this.socket.on("leave", (event) => { this.socket?.emit("leave", event); }); }; @@ -470,4 +481,4 @@ class WebsocketProvider extends React.Component { } } -export default withStores(WebsocketProvider); +export default withTranslation()(withStores(WebsocketProvider)); diff --git a/app/scenes/Settings/components/DropToImport.tsx b/app/scenes/Settings/components/DropToImport.tsx index 733f5f45cd54..64d6cb2f7511 100644 --- a/app/scenes/Settings/components/DropToImport.tsx +++ b/app/scenes/Settings/components/DropToImport.tsx @@ -42,9 +42,11 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) { }); await collections.import(attachment.id, format); onSubmit(); - toast.success( - t("Your import is being processed, you can safely leave this page") - ); + toast.message(file.name, { + description: t( + "Your import is being processed, you can safely leave this page" + ), + }); } catch (err) { toast.error(err.message); } finally { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index d334daa9b046..10be81c4b25e 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -267,6 +267,7 @@ "Save": "Save", "New name": "New name", "Name can't be empty": "Name can't be empty", + "Your import completed": "Your import completed", "Previous match": "Previous match", "Next match": "Next match", "Find and replace": "Find and replace", From d593976b4d515dfe569916f7d810d752fcb18824 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 29 Oct 2023 20:42:49 -0400 Subject: [PATCH 029/241] Add a button to upload images into comments (#6092) --- app/components/Editor.tsx | 4 +- app/editor/index.tsx | 20 +++++++ .../Document/components/CommentForm.tsx | 57 ++++++++++++++++--- shared/i18n/locales/en_US/translation.json | 1 + 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index c5f3e41bd475..2fb0c45f0845 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -50,7 +50,7 @@ export type Props = Optional< previewsDisabled?: boolean; onHeadingsChange?: (headings: Heading[]) => void; onSynced?: () => Promise; - onPublish?: (event: React.MouseEvent) => any; + onPublish?: (event: React.MouseEvent) => void; editorStyle?: React.CSSProperties; }; @@ -138,7 +138,7 @@ function Editor(props: Props, ref: React.RefObject | null) { : 1 ); }, - [documents] + [locale, documents] ); const handleUploadFile = React.useCallback( diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 9bd1218a966c..9d98cb318eee 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -23,6 +23,7 @@ import { import { Decoration, EditorView, NodeViewConstructor } from "prosemirror-view"; import * as React from "react"; import styled, { css, DefaultTheme, ThemeProps } from "styled-components"; +import insertFiles from "@shared/editor/commands/insertFiles"; import Styles from "@shared/editor/components/Styles"; import { EmbedDescriptor } from "@shared/editor/embeds"; import Extension, { CommandFactory } from "@shared/editor/lib/Extension"; @@ -584,6 +585,25 @@ export class Editor extends React.PureComponent< window?.getSelection()?.removeAllRanges(); }; + /** + * Insert files at the current selection. + * = + * @param event The source event + * @param files The files to insert + * @returns True if the files were inserted + */ + public insertFiles = ( + event: React.ChangeEvent, + files: File[] + ) => + insertFiles( + this.view, + event, + this.view.state.selection.to, + files, + this.props + ); + /** * Returns true if the trimmed content of the editor is an empty string. * diff --git a/app/scenes/Document/components/CommentForm.tsx b/app/scenes/Document/components/CommentForm.tsx index bde685bb69bc..6eae5663d7bd 100644 --- a/app/scenes/Document/components/CommentForm.tsx +++ b/app/scenes/Document/components/CommentForm.tsx @@ -1,16 +1,22 @@ import { m } from "framer-motion"; import { action } from "mobx"; import { observer } from "mobx-react"; +import { ImageIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import { VisuallyHidden } from "reakit"; import { toast } from "sonner"; +import { useTheme } from "styled-components"; import { v4 as uuidv4 } from "uuid"; -import { CommentValidation } from "@shared/validations"; +import { getEventFiles } from "@shared/utils/files"; +import { AttachmentValidation, CommentValidation } from "@shared/validations"; import Comment from "~/models/Comment"; import Avatar from "~/components/Avatar"; import ButtonSmall from "~/components/ButtonSmall"; import { useDocumentContext } from "~/components/DocumentContext"; import Flex from "~/components/Flex"; +import NudeButton from "~/components/NudeButton"; +import Tooltip from "~/components/Tooltip"; import type { Editor as SharedEditor } from "~/editor"; import useCurrentUser from "~/hooks/useCurrentUser"; import useOnClickOutside from "~/hooks/useOnClickOutside"; @@ -64,6 +70,8 @@ function CommentForm({ const editorRef = React.useRef(null); const [forceRender, setForceRender] = React.useState(0); const [inputFocused, setInputFocused] = React.useState(autoFocus); + const file = React.useRef(null); + const theme = useTheme(); const { t } = useTranslation(); const { comments } = useStores(); const user = useCurrentUser(); @@ -188,6 +196,23 @@ function CommentForm({ onBlur?.(); }; + const handleFilePicked = (event: React.ChangeEvent) => { + event.stopPropagation(); + event.preventDefault(); + + const files = getEventFiles(event); + if (!files.length) { + return; + } + editorRef.current?.insertFiles(event, files); + }; + + const handleImageUpload = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + file.current?.click(); + }; + // Focus the editor when it's a new comment just mounted, after a delay as the // editor is mounted within a fade transition. React.useEffect(() => { @@ -227,6 +252,15 @@ function CommentForm({ {...presence} {...rest} > + + + {(inputFocused || data) && ( - - - {thread && !thread.isNew ? t("Reply") : t("Post")} - - - {t("Cancel")} - + + + + {thread && !thread.isNew ? t("Reply") : t("Post")} + + + {t("Cancel")} + + + + + + + )} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 10be81c4b25e..07671e2e1798 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -500,6 +500,7 @@ "Reply": "Reply", "Post": "Post", "Cancel": "Cancel", + "Upload image": "Upload image", "No comments yet": "No comments yet", "Error updating comment": "Error updating comment", "Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?", From ed447f5811acf0b64f8da5c7ebb43ebc19add2b6 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 30 Oct 2023 08:41:09 -0400 Subject: [PATCH 030/241] fix: Eroneous toast on export --- app/components/WebsocketProvider.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index d5fd2ab05dfb..b0c980c270b5 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -6,7 +6,7 @@ import * as React from "react"; import { withTranslation, WithTranslation } from "react-i18next"; import { io, Socket } from "socket.io-client"; import { toast } from "sonner"; -import { FileOperationState } from "@shared/types"; +import { FileOperationState, FileOperationType } from "@shared/types"; import RootStore from "~/stores/RootStore"; import Collection from "~/models/Collection"; import Comment from "~/models/Comment"; @@ -436,6 +436,7 @@ class WebsocketProvider extends React.Component { if ( event.state === FileOperationState.Complete && + event.type === FileOperationType.Import && event.user?.id === auth.user?.id ) { toast.success(event.name, { From 07ce213232dfa4a9a77d8603d653513559d057a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:50:47 -0700 Subject: [PATCH 031/241] chore(deps): bump slugify from 1.6.5 to 1.6.6 (#6099) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 5433bd5e09d0..d6c60025a503 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,7 @@ "sequelize-encrypted": "^1.0.0", "sequelize-typescript": "^2.1.5", "slug": "^5.3.0", - "slugify": "^1.6.5", + "slugify": "^1.6.6", "smooth-scroll-into-view-if-needed": "^1.1.33", "socket.io": "^4.7.2", "socket.io-client": "^4.6.1", diff --git a/yarn.lock b/yarn.lock index 347fac488d68..a67e12866ce5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12024,10 +12024,10 @@ slug@^5.3.0: resolved "https://registry.yarnpkg.com/slug/-/slug-5.3.0.tgz#d63d3a5a88d5508c1adcf2b8aeeb045c3f43760b" integrity sha512-h7yD2UDVyMcQRv/WLSjq7HDH6ToO/22MB381zfx6/ebtdWUlGcyxpJNVHl6WFvKjIMHf5ZxANFp/srsy4mfT/w== -slugify@^1.6.5: - version "1.6.5" - resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.5.tgz#c8f5c072bf2135b80703589b39a3d41451fbe8c8" - integrity sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ== +slugify@^1.6.6: + version "1.6.6" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" + integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== smart-buffer@^4.2.0: version "4.2.0" From 5027ae9def7ab139a4ec095be41dec5073e960d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:50:54 -0700 Subject: [PATCH 032/241] chore(deps-dev): bump @types/natural-sort from 0.0.22 to 0.0.23 (#6096) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d6c60025a503..4b18ca93499d 100644 --- a/package.json +++ b/package.json @@ -272,7 +272,7 @@ "@types/markdown-it-emoji": "^2.0.2", "@types/mermaid": "^9.2.0", "@types/mime-types": "^2.1.1", - "@types/natural-sort": "^0.0.22", + "@types/natural-sort": "^0.0.23", "@types/node": "18.18.6", "@types/node-fetch": "^2.6.5", "@types/nodemailer": "^6.4.9", diff --git a/yarn.lock b/yarn.lock index a67e12866ce5..b34f90e0c6a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3220,10 +3220,10 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== -"@types/natural-sort@^0.0.22": - version "0.0.22" - resolved "https://registry.yarnpkg.com/@types/natural-sort/-/natural-sort-0.0.22.tgz#8d95d7a27dcd662286e1ddb3d759ddf7dca30588" - integrity sha512-7IoapJUZyGLwlUsmypKtUtvkVrxCKcpybcqU4JlvIOxeKwhBMelLoecK3o2aPIUjpxvOl6TKpSftNtP3IIAirw== +"@types/natural-sort@^0.0.23": + version "0.0.23" + resolved "https://registry.yarnpkg.com/@types/natural-sort/-/natural-sort-0.0.23.tgz#18dda0004d32f3b5102e924bddda10291a3ea2bc" + integrity sha512-jDd0NG5kObGSV2O07ePej8ArJBjb7HtH6Mjnxa4cEodMVdOsD+mID4loPX8+xcrgB7OfXIiiJf1fTO+EtMBRVA== "@types/node-fetch@^2.6.5": version "2.6.5" From 667e42e814eba4d054ebfb5e238ab4021147a616 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:51:09 -0700 Subject: [PATCH 033/241] chore(deps): bump fetch-retry from 5.0.5 to 5.0.6 (#6097) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 4b18ca93499d..37a6fcc06e78 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "emoji-mart": "^5.5.2", "emoji-regex": "^10.2.1", "es6-error": "^4.1.1", - "fetch-retry": "^5.0.5", + "fetch-retry": "^5.0.6", "fetch-with-proxy": "^3.0.1", "focus-visible": "^5.2.0", "form-data": "^4.0.0", diff --git a/yarn.lock b/yarn.lock index b34f90e0c6a4..d2fce92e71ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6801,10 +6801,10 @@ fecha@^4.2.0: resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.1.tgz#0a83ad8f86ef62a091e22bb5a039cd03d23eecce" integrity sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q== -fetch-retry@^5.0.5: - version "5.0.5" - resolved "https://registry.yarnpkg.com/fetch-retry/-/fetch-retry-5.0.5.tgz#61079b816b6651d88a022ebd45d51d83aa72b521" - integrity sha512-q9SvpKH5Ka6h7X2C6r1sP31pQoeDb3o6/R9cg21ahfPAqbIOkW9tus1dXfwYb6G6dOI4F7nVS4Q+LSssBGIz0A== +fetch-retry@^5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/fetch-retry/-/fetch-retry-5.0.6.tgz#17d0bc90423405b7a88b74355bf364acd2a7fa56" + integrity sha512-3yurQZ2hD9VISAhJJP9bpYFNQrHHBXE2JxxjY5aLEcDi46RmAzJE2OC9FAde0yis5ElW0jTTzs0zfg/Cca4XqQ== fetch-with-proxy@^3.0.1: version "3.0.1" From 44198732d379e6bfa09e6992c3a8c80e26891998 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:51:20 -0700 Subject: [PATCH 034/241] chore(deps-dev): bump @types/fs-extra from 11.0.1 to 11.0.3 (#6098) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 37a6fcc06e78..4742ed637b4c 100644 --- a/package.json +++ b/package.json @@ -249,7 +249,7 @@ "@types/enzyme-adapter-react-16": "^1.0.6", "@types/express-useragent": "^1.0.2", "@types/formidable": "^2.0.6", - "@types/fs-extra": "^11.0.1", + "@types/fs-extra": "^11.0.3", "@types/fuzzy-search": "^2.1.2", "@types/glob": "^8.0.1", "@types/google.analytics": "^0.0.42", diff --git a/yarn.lock b/yarn.lock index d2fce92e71ed..b7845308195a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2920,10 +2920,10 @@ dependencies: "@types/node" "*" -"@types/fs-extra@^11.0.1": - version "11.0.1" - resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.1.tgz#f542ec47810532a8a252127e6e105f487e0a6ea5" - integrity sha512-MxObHvNl4A69ofaTRU8DFqvgzzv8s9yRtaPPm5gud9HDNvpB3GPQFvNuTWAI59B9huVGV5jXYJwbCsmBsOGYWA== +"@types/fs-extra@^11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.3.tgz#72c3a247c8dd5703c93d900c584e006476146866" + integrity sha512-sF59BlXtUdzEAL1u0MSvuzWd7PdZvZEtnaVkzX5mjpdWTJ8brG0jUqve3jPCzSzvAKKMHTG8F8o/WMQLtleZdQ== dependencies: "@types/jsonfile" "*" "@types/node" "*" From df6d8c12cc16cd98d5976636978af6c365cb3de3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 31 Oct 2023 21:55:55 -0400 Subject: [PATCH 035/241] Refactor Editor components to be injected by associated extension (#6093) --- app/components/CollectionDescription.tsx | 12 ++ app/components/Editor.tsx | 24 --- app/components/HoverPreview/HoverPreview.tsx | 6 +- .../Notifications/NotificationListItem.tsx | 1 - app/editor/components/BlockMenu.tsx | 2 +- app/editor/components/EmojiMenu.tsx | 2 +- app/editor/components/MentionMenu.tsx | 2 +- app/editor/components/SuggestionsMenu.tsx | 9 +- .../editor/extensions/BlockMenu.tsx | 50 +++-- app/editor/extensions/EmojiMenu.tsx | 45 +++++ .../editor/extensions/FindAndReplace.tsx | 10 +- .../editor/extensions/HoverPreviews.tsx | 40 ++-- app/editor/extensions/MentionMenu.tsx | 31 ++++ app/editor/extensions/Suggestion.ts | 73 ++++++++ app/editor/index.tsx | 174 ++++-------------- app/editor/menus/block.tsx | 8 - .../Document/components/CommentEditor.tsx | 8 +- app/scenes/Document/components/Editor.tsx | 18 +- shared/editor/extensions/Suggestion.tsx | 65 ------- shared/editor/lib/Extension.ts | 30 +++ shared/editor/lib/ExtensionManager.ts | 28 ++- shared/editor/nodes/Emoji.tsx | 29 +-- shared/editor/nodes/Mention.ts | 15 +- shared/editor/nodes/index.ts | 6 - shared/editor/plugins/Suggestions.ts | 37 ++-- 25 files changed, 371 insertions(+), 354 deletions(-) rename {shared => app}/editor/extensions/BlockMenu.tsx (66%) create mode 100644 app/editor/extensions/EmojiMenu.tsx rename shared/editor/extensions/FindAndReplace.ts => app/editor/extensions/FindAndReplace.tsx (96%) rename shared/editor/extensions/HoverPreviews.ts => app/editor/extensions/HoverPreviews.tsx (62%) create mode 100644 app/editor/extensions/MentionMenu.tsx create mode 100644 app/editor/extensions/Suggestion.ts delete mode 100644 shared/editor/extensions/Suggestion.tsx diff --git a/app/components/CollectionDescription.tsx b/app/components/CollectionDescription.tsx index 37f953fe4ba9..58cb4c4c05d3 100644 --- a/app/components/CollectionDescription.tsx +++ b/app/components/CollectionDescription.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import styled from "styled-components"; +import { richExtensions } from "@shared/editor/nodes"; import { s } from "@shared/styles"; import Collection from "~/models/Collection"; import Arrow from "~/components/Arrow"; @@ -12,9 +13,19 @@ import ButtonLink from "~/components/ButtonLink"; import Editor from "~/components/Editor"; import LoadingIndicator from "~/components/LoadingIndicator"; import NudeButton from "~/components/NudeButton"; +import BlockMenuExtension from "~/editor/extensions/BlockMenu"; +import EmojiMenuExtension from "~/editor/extensions/EmojiMenu"; +import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; +const extensions = [ + ...richExtensions, + BlockMenuExtension, + EmojiMenuExtension, + HoverPreviewsExtension, +]; + type Props = { collection: Collection; }; @@ -104,6 +115,7 @@ function CollectionDescription({ collection }: Props) { readOnly={!isEditing} autoFocus={isEditing} onBlur={handleStopEditing} + extensions={extensions} maxLength={1000} embedsDisabled canUpdate diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 2fb0c45f0845..193faf0428b1 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -19,7 +19,6 @@ import { AttachmentValidation } from "@shared/validations"; import Document from "~/models/Document"; import ClickablePadding from "~/components/ClickablePadding"; import ErrorBoundary from "~/components/ErrorBoundary"; -import HoverPreview from "~/components/HoverPreview"; import type { Props as EditorProps, Editor as SharedEditor } from "~/editor"; import useCurrentUser from "~/hooks/useCurrentUser"; import useDictionary from "~/hooks/useDictionary"; @@ -47,7 +46,6 @@ export type Props = Optional< > & { shareId?: string | undefined; embedsDisabled?: boolean; - previewsDisabled?: boolean; onHeadingsChange?: (headings: Heading[]) => void; onSynced?: () => Promise; onPublish?: (event: React.MouseEvent) => void; @@ -62,7 +60,6 @@ function Editor(props: Props, ref: React.RefObject | null) { onHeadingsChange, onCreateCommentMark, onDeleteCommentMark, - previewsDisabled, } = props; const userLocale = useUserLocale(); const locale = dateLocale(userLocale); @@ -73,22 +70,8 @@ function Editor(props: Props, ref: React.RefObject | null) { const localRef = React.useRef(); const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences; const previousHeadings = React.useRef(null); - const [activeLinkElement, setActiveLink] = - React.useState(null); const previousCommentIds = React.useRef(); - const handleLinkActive = React.useCallback( - (element: HTMLAnchorElement | null) => { - setActiveLink(element); - return false; - }, - [] - ); - - const handleLinkInactive = React.useCallback(() => { - setActiveLink(null); - }, []); - const handleSearchLink = React.useCallback( async (term: string) => { if (isInternalUrl(term)) { @@ -339,7 +322,6 @@ function Editor(props: Props, ref: React.RefObject | null) { userPreferences={preferences} dictionary={dictionary} {...props} - onHoverLink={previewsDisabled ? undefined : handleLinkActive} onClickLink={handleClickLink} onSearchLink={handleSearchLink} onChange={handleChange} @@ -354,12 +336,6 @@ function Editor(props: Props, ref: React.RefObject | null) { minHeight={props.editorStyle.paddingBottom} /> )} - {!shareId && ( - - )} ); diff --git a/app/components/HoverPreview/HoverPreview.tsx b/app/components/HoverPreview/HoverPreview.tsx index 7854252892af..1e908daf84d4 100644 --- a/app/components/HoverPreview/HoverPreview.tsx +++ b/app/components/HoverPreview/HoverPreview.tsx @@ -24,7 +24,7 @@ const POINTER_WIDTH = 22; type Props = { /** The HTML element that is being hovered over, or null if none. */ - element: HTMLAnchorElement | null; + element: HTMLElement | null; /** A callback on close of the hover preview. */ onClose: () => void; }; @@ -35,7 +35,7 @@ enum Direction { } function HoverPreviewDesktop({ element, onClose }: Props) { - const url = element?.href || element?.dataset.url; + const url = element?.getAttribute("href") || element?.dataset.url; const previousUrl = usePrevious(url, true); const [isVisible, setVisible] = React.useState(false); const timerClose = React.useRef>(); @@ -200,7 +200,7 @@ function useHoverPosition({ isVisible, }: { cardRef: React.RefObject; - element: HTMLAnchorElement | null; + element: HTMLElement | null; isVisible: boolean; }) { const [cardLeft, setCardLeft] = React.useState(0); diff --git a/app/components/Notifications/NotificationListItem.tsx b/app/components/Notifications/NotificationListItem.tsx index 3f7f8e4aab65..bab0a78ea6bd 100644 --- a/app/components/Notifications/NotificationListItem.tsx +++ b/app/components/Notifications/NotificationListItem.tsx @@ -64,7 +64,6 @@ function NotificationListItem({ notification, onNavigate }: Props) { {notification.comment && ( )} diff --git a/app/editor/components/BlockMenu.tsx b/app/editor/components/BlockMenu.tsx index 06d600d3b2ca..0e697a11b6fc 100644 --- a/app/editor/components/BlockMenu.tsx +++ b/app/editor/components/BlockMenu.tsx @@ -10,7 +10,7 @@ type Props = Omit< SuggestionsMenuProps, "renderMenuItem" | "items" | "trigger" > & - Required>; + Required>; function BlockMenu(props: Props) { const dictionary = useDictionary(); diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx index 5c88588b692d..0cdb603c23dd 100644 --- a/app/editor/components/EmojiMenu.tsx +++ b/app/editor/components/EmojiMenu.tsx @@ -28,7 +28,7 @@ let searcher: FuzzySearch; type Props = Omit< SuggestionsMenuProps, - "renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "trigger" + "renderMenuItem" | "items" | "embeds" | "trigger" >; const EmojiMenu = (props: Props) => { diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index af0edc10f0fa..d656d74648f0 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -33,7 +33,7 @@ interface MentionItem extends MenuItem { type Props = Omit< SuggestionsMenuProps, - "renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "trigger" + "renderMenuItem" | "items" | "embeds" | "trigger" >; function MentionMenu({ search, isActive, ...rest }: Props) { diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx index 1866a65a0ddc..407a75209d22 100644 --- a/app/editor/components/SuggestionsMenu.tsx +++ b/app/editor/components/SuggestionsMenu.tsx @@ -60,7 +60,6 @@ export type Props = { uploadFile?: (file: File) => Promise; onFileUploadStart?: () => void; onFileUploadStop?: () => void; - onLinkToolbarOpen?: () => void; onClose: (insertNewLine?: boolean) => void; embeds?: EmbedDescriptor[]; renderMenuItem: ( @@ -252,17 +251,11 @@ function SuggestionsMenu(props: Props) { return triggerFilePick("*"); case "embed": return triggerLinkInput(item); - case "link": { - handleClearSearch(); - props.onClose(); - props.onLinkToolbarOpen?.(); - return; - } default: insertNode(item); } }, - [insertNode, handleClearSearch, props] + [insertNode] ); const close = React.useCallback(() => { diff --git a/shared/editor/extensions/BlockMenu.tsx b/app/editor/extensions/BlockMenu.tsx similarity index 66% rename from shared/editor/extensions/BlockMenu.tsx rename to app/editor/extensions/BlockMenu.tsx index f2173239aa51..8413b1a7ae99 100644 --- a/shared/editor/extensions/BlockMenu.tsx +++ b/app/editor/extensions/BlockMenu.tsx @@ -1,24 +1,24 @@ +import { action } from "mobx"; import { PlusIcon } from "outline-icons"; import { Plugin } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import * as React from "react"; import ReactDOM from "react-dom"; -import { SuggestionsMenuType } from "../plugins/Suggestions"; -import { findParentNode } from "../queries/findParentNode"; -import { EventType } from "../types"; -import Suggestion from "./Suggestion"; +import { WidgetProps } from "@shared/editor/lib/Extension"; +import { findParentNode } from "@shared/editor/queries/findParentNode"; +import Suggestion from "~/editor/extensions/Suggestion"; +import BlockMenu from "../components/BlockMenu"; -export default class BlockMenu extends Suggestion { +export default class BlockMenuExtension extends Suggestion { get defaultOptions() { return { - type: SuggestionsMenuType.Block, openRegex: /^\/(\w+)?$/, closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/, }; } get name() { - return "blockmenu"; + return "block-menu"; } get plugins() { @@ -54,12 +54,12 @@ export default class BlockMenu extends Suggestion { Decoration.widget( parent.pos, () => { - button.addEventListener("click", () => { - this.editor.events.emit(EventType.SuggestionsMenuOpen, { - type: SuggestionsMenuType.Block, - query: "", - }); - }); + button.addEventListener( + "click", + action(() => { + this.state.open = true; + }) + ); return button; }, { @@ -96,4 +96,28 @@ export default class BlockMenu extends Suggestion { }), ]; } + + widget = ({ rtl }: WidgetProps) => { + const { props, view } = this.editor; + return ( + { + if (insertNewLine) { + const transaction = view.state.tr.split(view.state.selection.to); + view.dispatch(transaction); + view.focus(); + } + + this.state.open = false; + })} + uploadFile={props.uploadFile} + onFileUploadStart={props.onFileUploadStart} + onFileUploadStop={props.onFileUploadStop} + embeds={props.embeds} + /> + ); + }; } diff --git a/app/editor/extensions/EmojiMenu.tsx b/app/editor/extensions/EmojiMenu.tsx new file mode 100644 index 000000000000..01312f2164cf --- /dev/null +++ b/app/editor/extensions/EmojiMenu.tsx @@ -0,0 +1,45 @@ +import { action } from "mobx"; +import * as React from "react"; +import { WidgetProps } from "@shared/editor/lib/Extension"; +import Suggestion from "~/editor/extensions/Suggestion"; +import EmojiMenu from "../components/EmojiMenu"; + +/** + * Languages using the colon character with a space in front in standard + * punctuation. In this case the trigger is only matched once there is additional + * text after the colon. + */ +const languagesUsingColon = ["fr"]; + +export default class EmojiMenuExtension extends Suggestion { + get defaultOptions() { + const languageIsUsingColon = + typeof window === "undefined" + ? false + : languagesUsingColon.includes(window.navigator.language.slice(0, 2)); + + return { + openRegex: new RegExp( + `(?:^|\\s):([0-9a-zA-Z_+-]+)${languageIsUsingColon ? "" : "?"}$` + ), + closeRegex: + /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/, + enabledInTable: true, + }; + } + + get name() { + return "emoji-menu"; + } + + widget = ({ rtl }: WidgetProps) => ( + { + this.state.open = false; + })} + /> + ); +} diff --git a/shared/editor/extensions/FindAndReplace.ts b/app/editor/extensions/FindAndReplace.tsx similarity index 96% rename from shared/editor/extensions/FindAndReplace.ts rename to app/editor/extensions/FindAndReplace.tsx index f70215989408..81e32f778a2a 100644 --- a/shared/editor/extensions/FindAndReplace.ts +++ b/app/editor/extensions/FindAndReplace.tsx @@ -2,12 +2,14 @@ import escapeRegExp from "lodash/escapeRegExp"; import { Node } from "prosemirror-model"; import { Command, Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; +import * as React from "react"; import scrollIntoView from "smooth-scroll-into-view-if-needed"; -import Extension from "../lib/Extension"; +import Extension from "@shared/editor/lib/Extension"; +import FindAndReplace from "../components/FindAndReplace"; const pluginKey = new PluginKey("find-and-replace"); -export default class FindAndReplace extends Extension { +export default class FindAndReplaceExtension extends Extension { public get name() { return "find-and-replace"; } @@ -292,6 +294,10 @@ export default class FindAndReplace extends Extension { ]; } + public widget = () => ( + + ); + private results: { from: number; to: number }[] = []; private currentResultIndex = 0; private searchTerm = ""; diff --git a/shared/editor/extensions/HoverPreviews.ts b/app/editor/extensions/HoverPreviews.tsx similarity index 62% rename from shared/editor/extensions/HoverPreviews.ts rename to app/editor/extensions/HoverPreviews.tsx index c3ba34ed23b3..6ddca9607fa2 100644 --- a/shared/editor/extensions/HoverPreviews.ts +++ b/app/editor/extensions/HoverPreviews.tsx @@ -1,16 +1,22 @@ +import { action, observable } from "mobx"; import { Plugin } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import Extension from "../lib/Extension"; +import * as React from "react"; +import Extension from "@shared/editor/lib/Extension"; +import HoverPreview from "~/components/HoverPreview"; interface HoverPreviewsOptions { - /** Callback when a hover target is found or lost. */ - onHoverLink?: (target: Element | null) => void; - /** Delay before the target is considered "hovered" and callback is triggered. */ delay: number; } export default class HoverPreviews extends Extension { + state: { + activeLinkElement: HTMLElement | null; + } = observable({ + activeLinkElement: null, + }); + get defaultOptions(): HoverPreviewsOptions { return { delay: 500, @@ -38,27 +44,37 @@ export default class HoverPreviews extends Extension { ".use-hover-preview" ); if (isHoverTarget(target, view)) { - if (this.options.onHoverLink) { - hoveringTimeout = setTimeout(() => { - this.options.onHoverLink?.(target); - }, this.options.delay); - } + hoveringTimeout = setTimeout( + action(() => { + this.state.activeLinkElement = target as HTMLElement; + }), + this.options.delay + ); } return false; }, - mouseout: (view: EditorView, event: MouseEvent) => { + mouseout: action((view: EditorView, event: MouseEvent) => { const target = (event.target as HTMLElement)?.closest( ".use-hover-preview" ); if (isHoverTarget(target, view)) { clearTimeout(hoveringTimeout); - this.options.onHoverLink?.(null); + this.state.activeLinkElement = null; } return false; - }, + }), }, }, }), ]; } + + widget = () => ( + { + this.state.activeLinkElement = null; + })} + /> + ); } diff --git a/app/editor/extensions/MentionMenu.tsx b/app/editor/extensions/MentionMenu.tsx new file mode 100644 index 000000000000..a6e7f1cdb0de --- /dev/null +++ b/app/editor/extensions/MentionMenu.tsx @@ -0,0 +1,31 @@ +import { action } from "mobx"; +import * as React from "react"; +import { WidgetProps } from "@shared/editor/lib/Extension"; +import Suggestion from "~/editor/extensions/Suggestion"; +import MentionMenu from "../components/MentionMenu"; + +export default class MentionMenuExtension extends Suggestion { + get defaultOptions() { + return { + // ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w + openRegex: /(?:^|\s)@([\p{L}\p{M}\d]+)?$/u, + closeRegex: /(?:^|\s)@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u, + enabledInTable: true, + }; + } + + get name() { + return "mention-menu"; + } + + widget = ({ rtl }: WidgetProps) => ( + { + this.state.open = false; + })} + /> + ); +} diff --git a/app/editor/extensions/Suggestion.ts b/app/editor/extensions/Suggestion.ts new file mode 100644 index 000000000000..e6c328dc2f64 --- /dev/null +++ b/app/editor/extensions/Suggestion.ts @@ -0,0 +1,73 @@ +import { action, observable } from "mobx"; +import { InputRule } from "prosemirror-inputrules"; +import { NodeType, Schema } from "prosemirror-model"; +import { EditorState, Plugin } from "prosemirror-state"; +import { isInTable } from "prosemirror-tables"; +import Extension from "@shared/editor/lib/Extension"; +import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions"; +import isInCode from "@shared/editor/queries/isInCode"; + +export default class Suggestion extends Extension { + state: { + open: boolean; + query: string; + } = observable({ + open: false, + query: "", + }); + + get plugins(): Plugin[] { + return [new SuggestionsMenuPlugin(this.options, this.state)]; + } + + keys() { + return { + Backspace: action((state: EditorState) => { + const { $from } = state.selection; + const textBefore = $from.parent.textBetween( + Math.max(0, $from.parentOffset - 500), // 500 = max match + Math.max(0, $from.parentOffset - 1), // 1 = account for deleted character + null, + "\ufffc" + ); + + if (this.options.openRegex.test(textBefore)) { + return false; + } + + this.state.open = false; + return false; + }), + }; + } + + inputRules = (_options: { type: NodeType; schema: Schema }) => [ + new InputRule( + this.options.openRegex, + action((state: EditorState, match: RegExpMatchArray) => { + const { parent } = state.selection.$from; + if ( + match && + (parent.type.name === "paragraph" || + parent.type.name === "heading") && + (!isInCode(state) || this.options.enabledInCode) && + (!isInTable(state) || this.options.enabledInTable) + ) { + this.state.open = true; + this.state.query = match[1]; + } + return null; + }) + ), + new InputRule( + this.options.closeRegex, + action((_: EditorState, match: RegExpMatchArray) => { + if (match) { + this.state.open = false; + this.state.query = ""; + } + return null; + }) + ), + ]; +} diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 9d98cb318eee..b58b79fb9a76 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -26,15 +26,17 @@ import styled, { css, DefaultTheme, ThemeProps } from "styled-components"; import insertFiles from "@shared/editor/commands/insertFiles"; import Styles from "@shared/editor/components/Styles"; import { EmbedDescriptor } from "@shared/editor/embeds"; -import Extension, { CommandFactory } from "@shared/editor/lib/Extension"; +import Extension, { + CommandFactory, + WidgetProps, +} from "@shared/editor/lib/Extension"; import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"; import textBetween from "@shared/editor/lib/textBetween"; import Mark from "@shared/editor/marks/Mark"; -import { richExtensions, withComments } from "@shared/editor/nodes"; +import { basicExtensions as extensions } from "@shared/editor/nodes"; import Node from "@shared/editor/nodes/Node"; import ReactNode from "@shared/editor/nodes/ReactNode"; -import { SuggestionsMenuType } from "@shared/editor/plugins/Suggestions"; import { EventType } from "@shared/editor/types"; import { UserPreferences } from "@shared/types"; import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper"; @@ -43,21 +45,13 @@ import Flex from "~/components/Flex"; import { PortalContext } from "~/components/Portal"; import { Dictionary } from "~/hooks/useDictionary"; import Logger from "~/utils/Logger"; -import BlockMenu from "./components/BlockMenu"; import ComponentView from "./components/ComponentView"; import EditorContext from "./components/EditorContext"; -import EmojiMenu from "./components/EmojiMenu"; -import FindAndReplace from "./components/FindAndReplace"; import { SearchResult } from "./components/LinkEditor"; import LinkToolbar from "./components/LinkToolbar"; -import MentionMenu from "./components/MentionMenu"; import SelectionToolbar from "./components/SelectionToolbar"; import WithTheme from "./components/WithTheme"; -const extensions = withComments(richExtensions); - -export { default as Extension } from "@shared/editor/lib/Extension"; - export type Props = { /** An optional identifier for the editor context. It is used to persist local settings */ id?: string; @@ -124,8 +118,6 @@ export type Props = { href: string, event: MouseEvent | React.MouseEvent ) => void; - /** Callback when user hovers on any link in the document */ - onHoverLink?: (element: HTMLAnchorElement | null) => boolean; /** Callback when user presses any key with document focused */ onKeyDown?: (event: React.KeyboardEvent) => void; /** Collection of embed types to render in the document */ @@ -148,12 +140,8 @@ type State = { isEditorFocused: boolean; /** If the toolbar for a text selection is visible */ selectionToolbarOpen: boolean; - /** If a suggestions menu is visible */ - suggestionsMenuOpen: SuggestionsMenuType | false; /** If the insert link toolbar is visible */ linkToolbarOpen: boolean; - /** The query for the suggestion menu */ - query: string; }; /** @@ -182,10 +170,8 @@ export class Editor extends React.PureComponent< state: State = { isRTL: false, isEditorFocused: false, - suggestionsMenuOpen: false, selectionToolbarOpen: false, linkToolbarOpen: false, - query: "", }; isBlurred = true; @@ -204,6 +190,7 @@ export class Editor extends React.PureComponent< [name: string]: NodeViewConstructor; }; + widgets: { [name: string]: (props: WidgetProps) => React.ReactElement }; nodes: { [name: string]: NodeSpec }; marks: { [name: string]: MarkSpec }; commands: Record; @@ -214,14 +201,6 @@ export class Editor extends React.PureComponent< public constructor(props: Props & ThemeProps) { super(props); this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar); - this.events.on( - EventType.SuggestionsMenuOpen, - this.handleOpenSuggestionsMenu - ); - this.events.on( - EventType.SuggestionsMenuClose, - this.handleCloseSuggestionsMenu - ); } /** @@ -279,7 +258,6 @@ export class Editor extends React.PureComponent< if ( !this.isBlurred && !this.state.isEditorFocused && - !this.state.suggestionsMenuOpen && !this.state.linkToolbarOpen && !this.state.selectionToolbarOpen ) { @@ -290,7 +268,6 @@ export class Editor extends React.PureComponent< if ( this.isBlurred && (this.state.isEditorFocused || - this.state.suggestionsMenuOpen || this.state.linkToolbarOpen || this.state.selectionToolbarOpen) ) { @@ -310,6 +287,7 @@ export class Editor extends React.PureComponent< this.nodes = this.createNodes(); this.marks = this.createMarks(); this.schema = this.createSchema(); + this.widgets = this.createWidgets(); this.plugins = this.createPlugins(); this.rulePlugins = this.createRulePlugins(); this.keymaps = this.createKeymaps(); @@ -378,6 +356,10 @@ export class Editor extends React.PureComponent< }); } + private createWidgets() { + return this.extensions.widgets; + } + private createNodes() { return this.extensions.nodes; } @@ -702,8 +684,6 @@ export class Editor extends React.PureComponent< this.setState((state) => ({ ...state, selectionToolbarOpen: true, - suggestionsMenuOpen: false, - query: "", })); }; @@ -720,9 +700,7 @@ export class Editor extends React.PureComponent< private handleOpenLinkToolbar = () => { this.setState((state) => ({ ...state, - suggestionsMenuOpen: false, linkToolbarOpen: true, - query: "", })); }; @@ -733,37 +711,6 @@ export class Editor extends React.PureComponent< })); }; - private handleOpenSuggestionsMenu = (data: { - type: SuggestionsMenuType; - query: string; - }) => { - this.setState((state) => ({ - ...state, - suggestionsMenuOpen: data.type, - query: data.query, - })); - }; - - private handleCloseSuggestionsMenu = ( - type: SuggestionsMenuType, - insertNewLine?: boolean - ) => { - if (insertNewLine) { - const transaction = this.view.state.tr.split( - this.view.state.selection.to - ); - this.view.dispatch(transaction); - this.view.focus(); - } - if (type && this.state.suggestionsMenuOpen !== type) { - return; - } - this.setState((state) => ({ - ...state, - suggestionsMenuOpen: false, - })); - }; - public render() { const { dir, readOnly, canUpdate, grow, style, className, onKeyDown } = this.props; @@ -792,84 +739,31 @@ export class Editor extends React.PureComponent< ref={this.elementRef} /> {this.view && ( - <> - - {this.commands.find && } - + )} - {!readOnly && this.view && ( - <> - {this.marks.link && ( - - )} - {this.nodes.emoji && ( - - this.handleCloseSuggestionsMenu( - SuggestionsMenuType.Emoji, - insertNewLine - ) - } - /> - )} - {this.nodes.mention && ( - - this.handleCloseSuggestionsMenu( - SuggestionsMenuType.Mention, - insertNewLine - ) - } - /> - )} - - this.handleCloseSuggestionsMenu( - SuggestionsMenuType.Block, - insertNewLine - ) - } - uploadFile={this.props.uploadFile} - onLinkToolbarOpen={this.handleOpenLinkToolbar} - onFileUploadStart={this.props.onFileUploadStart} - onFileUploadStop={this.props.onFileUploadStop} - embeds={this.props.embeds} - /> - + {!readOnly && this.view && this.marks.link && ( + )} + {this.widgets && + Object.values(this.widgets).map((Widget, index) => ( + + ))} diff --git a/app/editor/menus/block.tsx b/app/editor/menus/block.tsx index f795fc56da47..301c2610d37a 100644 --- a/app/editor/menus/block.tsx +++ b/app/editor/menus/block.tsx @@ -14,7 +14,6 @@ import { StarredIcon, WarningIcon, InfoIcon, - LinkIcon, AttachmentIcon, ClockIcon, CalendarIcon, @@ -95,13 +94,6 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] { icon: , keywords: "picture photo", }, - { - name: "link", - title: dictionary.link, - icon: , - shortcut: `${metaDisplay} k`, - keywords: "link url uri href", - }, { name: "video", title: dictionary.video, diff --git a/app/scenes/Document/components/CommentEditor.tsx b/app/scenes/Document/components/CommentEditor.tsx index de9e8e70c959..4b74b7821aa1 100644 --- a/app/scenes/Document/components/CommentEditor.tsx +++ b/app/scenes/Document/components/CommentEditor.tsx @@ -2,8 +2,14 @@ import * as React from "react"; import { basicExtensions, withComments } from "@shared/editor/nodes"; import Editor, { Props as EditorProps } from "~/components/Editor"; import type { Editor as SharedEditor } from "~/editor"; +import EmojiMenuExtension from "~/editor/extensions/EmojiMenu"; +import MentionMenuExtension from "~/editor/extensions/MentionMenu"; -const extensions = withComments(basicExtensions); +const extensions = [ + ...withComments(basicExtensions), + EmojiMenuExtension, + MentionMenuExtension, +]; const CommentEditor = ( props: EditorProps, diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 1c638ac7419a..4a24fcfc9925 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -8,8 +8,14 @@ import { TeamPreference } from "@shared/types"; import Comment from "~/models/Comment"; import Document from "~/models/Document"; import { RefHandle } from "~/components/ContentEditable"; +import { useDocumentContext } from "~/components/DocumentContext"; import Editor, { Props as EditorProps } from "~/components/Editor"; import Flex from "~/components/Flex"; +import BlockMenuExtension from "~/editor/extensions/BlockMenu"; +import EmojiMenuExtension from "~/editor/extensions/EmojiMenu"; +import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace"; +import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews"; +import MentionMenuExtension from "~/editor/extensions/MentionMenu"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; import useFocusedComment from "~/hooks/useFocusedComment"; @@ -20,14 +26,20 @@ import { documentPath, matchDocumentHistory, } from "~/utils/routeHelpers"; -import { useDocumentContext } from "../../../components/DocumentContext"; import MultiplayerEditor from "./AsyncMultiplayerEditor"; import DocumentMeta from "./DocumentMeta"; import DocumentTitle from "./DocumentTitle"; -const extensions = withComments(richExtensions); +const extensions = [ + ...withComments(richExtensions), + BlockMenuExtension, + EmojiMenuExtension, + MentionMenuExtension, + FindAndReplaceExtension, + HoverPreviewsExtension, +]; -type Props = Omit & { +type Props = Omit & { onChangeTitle: (title: string) => void; onChangeEmoji: (emoji: string | null) => void; id: string; diff --git a/shared/editor/extensions/Suggestion.tsx b/shared/editor/extensions/Suggestion.tsx deleted file mode 100644 index 916b0ae59b6b..000000000000 --- a/shared/editor/extensions/Suggestion.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { InputRule } from "prosemirror-inputrules"; -import { NodeType, Schema } from "prosemirror-model"; -import { EditorState, Plugin } from "prosemirror-state"; -import { isInTable } from "prosemirror-tables"; -import Extension from "../lib/Extension"; -import { SuggestionsMenuPlugin } from "../plugins/Suggestions"; -import isInCode from "../queries/isInCode"; -import { EventType } from "../types"; - -export default class Suggestion extends Extension { - get plugins(): Plugin[] { - return [new SuggestionsMenuPlugin(this.editor, this.options)]; - } - - keys() { - return { - Backspace: (state: EditorState) => { - const { $from } = state.selection; - const textBefore = $from.parent.textBetween( - Math.max(0, $from.parentOffset - 500), // 500 = max match - Math.max(0, $from.parentOffset - 1), // 1 = account for deleted character - null, - "\ufffc" - ); - - if (this.options.openRegex.test(textBefore)) { - return false; - } - - this.editor.events.emit( - EventType.SuggestionsMenuClose, - this.options.type - ); - return false; - }, - }; - } - - inputRules = (_options: { type: NodeType; schema: Schema }) => [ - new InputRule(this.options.openRegex, (state, match) => { - const { parent } = state.selection.$from; - if ( - match && - (parent.type.name === "paragraph" || parent.type.name === "heading") && - (!isInCode(state) || this.options.enabledInCode) && - (!isInTable(state) || this.options.enabledInTable) - ) { - this.editor.events.emit(EventType.SuggestionsMenuOpen, { - type: this.options.type, - query: match[1], - }); - } - return null; - }), - new InputRule(this.options.closeRegex, (state, match) => { - if (match) { - this.editor.events.emit( - EventType.SuggestionsMenuClose, - this.options.type - ); - } - return null; - }), - ]; -} diff --git a/shared/editor/lib/Extension.ts b/shared/editor/lib/Extension.ts index 03f7ee01dddf..90552d832034 100644 --- a/shared/editor/lib/Extension.ts +++ b/shared/editor/lib/Extension.ts @@ -7,6 +7,8 @@ import { Editor } from "../../../app/editor"; export type CommandFactory = (attrs?: Record) => Command; +export type WidgetProps = { rtl: boolean }; + export default class Extension { options: any; editor: Editor; @@ -50,6 +52,22 @@ export default class Extension { return true; } + /** + * A widget is a React component to be rendered in the editor's context, independent of any + * specific node or mark. It can be used to render things like toolbars, menus, etc. Note that + * all widgets are observed automatically, so you can use observable values. + * + * @returns A React component + */ + widget(_props: WidgetProps): React.ReactElement | undefined { + return undefined; + } + + /** + * A map of ProseMirror keymap bindings. It can be used to bind keyboard shortcuts to commands. + * + * @returns An object mapping key bindings to commands + */ keys(_options: { type?: NodeType | MarkType; schema: Schema; @@ -57,6 +75,12 @@ export default class Extension { return {}; } + /** + * A map of ProseMirror input rules. It can be used to automatically replace certain patterns + * while typing. + * + * @returns An array of input rules + */ inputRules(_options: { type?: NodeType | MarkType; schema: Schema; @@ -64,6 +88,12 @@ export default class Extension { return []; } + /** + * A map of ProseMirror commands. It can be used to expose commands to the editor. If a single + * command is returned, it will be available under the extension's name. + * + * @returns An object mapping command names to command factories, or a command factory + */ commands(_options: { type?: NodeType | MarkType; schema: Schema; diff --git a/shared/editor/lib/ExtensionManager.ts b/shared/editor/lib/ExtensionManager.ts index 27d083e2259c..b0d418766107 100644 --- a/shared/editor/lib/ExtensionManager.ts +++ b/shared/editor/lib/ExtensionManager.ts @@ -1,4 +1,5 @@ import { PluginSimple } from "markdown-it"; +import { observer } from "mobx-react"; import { keymap } from "prosemirror-keymap"; import { MarkdownParser } from "prosemirror-markdown"; import { Schema } from "prosemirror-model"; @@ -41,8 +42,20 @@ export default class ExtensionManager { }); } - get nodes() { + get widgets() { return this.extensions + .filter((extension) => extension.widget({ rtl: false })) + .reduce( + (nodes, node: Node) => ({ + ...nodes, + [node.name]: observer(node.widget as any), + }), + {} + ); + } + + get nodes() { + const nodes = this.extensions .filter((extension) => extension.type === "node") .reduce( (nodes, node: Node) => ({ @@ -51,6 +64,19 @@ export default class ExtensionManager { }), {} ); + + for (const i in nodes) { + if (nodes[i].marks) { + // We must filter marks from the marks list that are not defined + // in the schema for the current editor. + nodes[i].marks = nodes[i].marks + .split(" ") + .filter((m: string) => Object.keys(nodes).includes(m)) + .join(" "); + } + } + + return nodes; } get marks() { diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx index 3f5845c102ba..5ba8bb9e8299 100644 --- a/shared/editor/nodes/Emoji.tsx +++ b/shared/editor/nodes/Emoji.tsx @@ -7,41 +7,16 @@ import { } from "prosemirror-model"; import { Command, TextSelection } from "prosemirror-state"; import { Primitive } from "utility-types"; -import Suggestion from "../extensions/Suggestion"; +import Extension from "../lib/Extension"; import { getEmojiFromName } from "../lib/emoji"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; -import { SuggestionsMenuType } from "../plugins/Suggestions"; import emojiRule from "../rules/emoji"; -/** - * Languages using the colon character with a space in front in standard - * punctuation. In this case the trigger is only matched once there is additional - * text after the colon. - */ -const languagesUsingColon = ["fr"]; - -export default class Emoji extends Suggestion { +export default class Emoji extends Extension { get type() { return "node"; } - get defaultOptions() { - const languageIsUsingColon = - typeof window === "undefined" - ? false - : languagesUsingColon.includes(window.navigator.language.slice(0, 2)); - - return { - type: SuggestionsMenuType.Emoji, - openRegex: new RegExp( - `(?:^|\\s):([0-9a-zA-Z_+-]+)${languageIsUsingColon ? "" : "?"}$` - ), - closeRegex: - /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/, - enabledInTable: true, - }; - } - get name() { return "emoji"; } diff --git a/shared/editor/nodes/Mention.ts b/shared/editor/nodes/Mention.ts index 18fef134f59b..761fdabb53d4 100644 --- a/shared/editor/nodes/Mention.ts +++ b/shared/editor/nodes/Mention.ts @@ -7,26 +7,15 @@ import { } from "prosemirror-model"; import { Command, TextSelection } from "prosemirror-state"; import { Primitive } from "utility-types"; -import Suggestion from "../extensions/Suggestion"; +import Extension from "../lib/Extension"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; -import { SuggestionsMenuType } from "../plugins/Suggestions"; import mentionRule from "../rules/mention"; -export default class Mention extends Suggestion { +export default class Mention extends Extension { get type() { return "node"; } - get defaultOptions() { - return { - type: SuggestionsMenuType.Mention, - // ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w - openRegex: /(?:^|\s)@([\p{L}\p{M}\d]+)?$/u, - closeRegex: /(?:^|\s)@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u, - enabledInTable: true, - }; - } - get name() { return "mention"; } diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 917aa4deb45b..797c5f99bc91 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -1,9 +1,6 @@ -import BlockMenu from "../extensions/BlockMenu"; import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer"; import DateTime from "../extensions/DateTime"; -import FindAndReplace from "../extensions/FindAndReplace"; import History from "../extensions/History"; -import HoverPreviews from "../extensions/HoverPreviews"; import Keys from "../extensions/Keys"; import MaxLength from "../extensions/MaxLength"; import PasteHandler from "../extensions/PasteHandler"; @@ -109,12 +106,9 @@ export const richExtensions: Nodes = [ TableRow, Highlight, TemplatePlaceholder, - BlockMenu, Math, MathBlock, PreventTab, - FindAndReplace, - HoverPreviews, ]; /** diff --git a/shared/editor/plugins/Suggestions.ts b/shared/editor/plugins/Suggestions.ts index 5d04ae99fbf1..dbfd8d1efee3 100644 --- a/shared/editor/plugins/Suggestions.ts +++ b/shared/editor/plugins/Suggestions.ts @@ -1,32 +1,25 @@ +import { action } from "mobx"; import { EditorState, Plugin } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import type { Editor } from "../../../app/editor"; -import { EventType } from "../types"; const MAX_MATCH = 500; -export enum SuggestionsMenuType { - Emoji = "emoji", - Block = "block", - Mention = "mention", -} - type Options = { - type: SuggestionsMenuType; openRegex: RegExp; closeRegex: RegExp; enabledInCode: true; enabledInTable: true; }; +type ExtensionState = { + open: boolean; + query: string; +}; + export class SuggestionsMenuPlugin extends Plugin { - constructor(editor: Editor, options: Options) { + constructor(options: Options, extensionState: ExtensionState) { super({ props: { - handleClick: () => { - editor.events.emit(options.type); - return false; - }, handleKeyDown: (view, event) => { // Prosemirror input rules are not triggered on backspace, however // we need them to be evaluted for the filter trigger to work @@ -41,20 +34,16 @@ export class SuggestionsMenuPlugin extends Plugin { pos, pos, options.openRegex, - (state, match) => { + action((_, match) => { if (match) { - editor.events.emit(EventType.SuggestionsMenuOpen, { - type: options.type, - query: match[1], - }); + extensionState.open = true; + extensionState.query = match[1]; } else { - editor.events.emit( - EventType.SuggestionsMenuClose, - options.type - ); + extensionState.open = false; + extensionState.query = ""; } return null; - } + }) ); }); } From 4af45c68cc06d635edafc98c1ec3fbee2760a17c Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 30 Oct 2023 22:16:38 -0400 Subject: [PATCH 036/241] Remove unused InputRich component --- app/components/InputRich.tsx | 63 ------------------------------------ 1 file changed, 63 deletions(-) delete mode 100644 app/components/InputRich.tsx diff --git a/app/components/InputRich.tsx b/app/components/InputRich.tsx deleted file mode 100644 index d4ec3f1e3b5b..000000000000 --- a/app/components/InputRich.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { observer } from "mobx-react"; -import * as React from "react"; -import { Trans } from "react-i18next"; -import styled from "styled-components"; -import Editor from "~/components/Editor"; -import { LabelText, Outline } from "~/components/Input"; -import Text from "~/components/Text"; - -type Props = { - label: string; - minHeight?: number; - maxHeight?: number; - readOnly?: boolean; -}; - -function InputRich({ label, minHeight, maxHeight, ...rest }: Props) { - const [focused, setFocused] = React.useState(false); - const handleBlur = React.useCallback(() => { - setFocused(false); - }, []); - const handleFocus = React.useCallback(() => { - setFocused(true); - }, []); - - return ( - <> - {label} - - - Loading editor… - - } - > - - - - - ); -} - -const StyledOutline = styled(Outline)<{ - minHeight?: number; - maxHeight?: number; - focused?: boolean; -}>` - display: block; - padding: 8px 12px; - min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")}; - max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "auto")}; - overflow-y: auto; - - > * { - display: block; - } -`; - -export default observer(InputRich); From a9ff0c245d244b2fdab38c3e6e66fd0c2070bce5 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 31 Oct 2023 22:10:55 -0400 Subject: [PATCH 037/241] Toggle current todo item with Mod-Enter --- shared/editor/commands/toggleBlockType.ts | 7 ++++ shared/editor/commands/toggleCheckboxItem.ts | 34 ++++++++++++++++++++ shared/editor/nodes/CheckboxItem.ts | 2 ++ shared/editor/nodes/index.ts | 3 +- shared/i18n/locales/en_US/translation.json | 1 - 5 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 shared/editor/commands/toggleCheckboxItem.ts diff --git a/shared/editor/commands/toggleBlockType.ts b/shared/editor/commands/toggleBlockType.ts index 2fd4d4b9c4d9..50af07e5e8e6 100644 --- a/shared/editor/commands/toggleBlockType.ts +++ b/shared/editor/commands/toggleBlockType.ts @@ -3,6 +3,13 @@ import { NodeType } from "prosemirror-model"; import { Command } from "prosemirror-state"; import isNodeActive from "../queries/isNodeActive"; +/** + * Toggles the block type of the current selection between the given type and the toggle type. + * + * @param type The node type + * @param toggleType The toggle node type + * @returns A prosemirror command. + */ export default function toggleBlockType( type: NodeType, toggleType: NodeType, diff --git a/shared/editor/commands/toggleCheckboxItem.ts b/shared/editor/commands/toggleCheckboxItem.ts new file mode 100644 index 000000000000..74ce909c1777 --- /dev/null +++ b/shared/editor/commands/toggleCheckboxItem.ts @@ -0,0 +1,34 @@ +import { Command } from "prosemirror-state"; +import { findParentNode } from "@shared/editor/queries/findParentNode"; + +/** + * A prosemirror command to toggle the checkbox item at the current selection. + * + * @returns A prosemirror command. + */ +export default function toggleCheckboxItem(): Command { + return (state, dispatch) => { + const { empty } = state.selection; + + // if the selection has anything in it then use standard behavior + if (!empty) { + return false; + } + + // check we're in a matching node + const listItem = findParentNode( + (node) => node.type === state.schema.nodes.checkbox_item + )(state.selection); + + if (!listItem) { + return false; + } + + dispatch?.( + state.tr.setNodeMarkup(listItem.pos, undefined, { + checked: !listItem.node.attrs.checked, + }) + ); + return true; + }; +} diff --git a/shared/editor/nodes/CheckboxItem.ts b/shared/editor/nodes/CheckboxItem.ts index 0a947ca76ed5..bf743eeef67a 100644 --- a/shared/editor/nodes/CheckboxItem.ts +++ b/shared/editor/nodes/CheckboxItem.ts @@ -5,6 +5,7 @@ import { sinkListItem, liftListItem, } from "prosemirror-schema-list"; +import toggleCheckboxItem from "../commands/toggleCheckboxItem"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; import checkboxRule from "../rules/checkboxes"; import Node from "./Node"; @@ -93,6 +94,7 @@ export default class CheckboxItem extends Node { checked: false, }), Tab: sinkListItem(type), + "Mod-Enter": toggleCheckboxItem(), "Shift-Tab": liftListItem(type), "Mod-]": sinkListItem(type), "Mod-[": liftListItem(type), diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 797c5f99bc91..68daefe47cee 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -83,7 +83,7 @@ export const basicExtensions: Nodes = [ * editors that need advanced formatting. */ export const richExtensions: Nodes = [ - ...basicExtensions.filter((n) => n !== SimpleImage), + ...basicExtensions.filter((n) => n !== SimpleImage && n !== Keys), Image, HardBreak, CodeBlock, @@ -109,6 +109,7 @@ export const richExtensions: Nodes = [ Math, MathBlock, PreventTab, + Keys, ]; /** diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 07671e2e1798..a3fb56f09c79 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -211,7 +211,6 @@ "Choose icon": "Choose icon", "Loading": "Loading", "Select a color": "Select a color", - "Loading editor": "Loading editor", "Search": "Search", "Default access": "Default access", "View and edit": "View and edit", From 0700e2f5eff186f006def58f701bd9212207cec7 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 31 Oct 2023 22:20:31 -0400 Subject: [PATCH 038/241] =?UTF-8?q?fix:=20Incorrect=20import=20=E2=80=93?= =?UTF-8?q?=20really=20need=20a=20lint=20rule=20for=20this?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/editor/commands/toggleCheckboxItem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/editor/commands/toggleCheckboxItem.ts b/shared/editor/commands/toggleCheckboxItem.ts index 74ce909c1777..ef64e51a9638 100644 --- a/shared/editor/commands/toggleCheckboxItem.ts +++ b/shared/editor/commands/toggleCheckboxItem.ts @@ -1,5 +1,5 @@ import { Command } from "prosemirror-state"; -import { findParentNode } from "@shared/editor/queries/findParentNode"; +import { findParentNode } from "../queries/findParentNode"; /** * A prosemirror command to toggle the checkbox item at the current selection. From 1d6ef2e1b3140231abf7d422e8799c8e873d95b8 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 31 Oct 2023 22:32:29 -0400 Subject: [PATCH 039/241] perf: Remove unneeded query before custom domain redirect --- server/routes/index.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/routes/index.ts b/server/routes/index.ts index ecd0abee8929..8c3b072fff7d 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -130,6 +130,13 @@ router.get("/s/:shareId/*", renderShare); // catch all for application router.get("*", async (ctx, next) => { const team = await getTeamFromContext(ctx); + + // Redirect all requests to custom domain if one is set + if (team?.domain && team.domain !== ctx.hostname) { + ctx.redirect(ctx.href.replace(ctx.hostname, team.domain)); + return; + } + const analytics = team ? await Integration.findOne({ where: { @@ -139,12 +146,6 @@ router.get("*", async (ctx, next) => { }) : undefined; - // Redirect all requests to custom domain if one is set - if (team?.domain && team.domain !== ctx.hostname) { - ctx.redirect(ctx.href.replace(ctx.hostname, team.domain)); - return; - } - return renderApp(ctx, next, { analytics, }); From 2838503273d70b8585d1c4291d89f4d3c68c5473 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 1 Nov 2023 22:10:00 -0400 Subject: [PATCH 040/241] Backend of public sharing at root (#6103) --- app/routes/index.tsx | 50 +++++++++++-------- app/scenes/Document/Shared.tsx | 4 +- app/utils/routeHelpers.ts | 5 ++ .../migrations/20231101021239-share-domain.js | 15 ++++++ server/models/Share.ts | 36 +++++++++++++ server/models/Team.ts | 26 ++++++++++ server/presenters/env.ts | 11 ++-- server/routes/app.ts | 9 +++- server/routes/index.ts | 23 ++++++++- server/utils/passport.ts | 2 + shared/types.ts | 1 + 11 files changed, 153 insertions(+), 29 deletions(-) create mode 100644 server/migrations/20231101021239-share-domain.js diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 89af249934ca..17dcd47c27ad 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -4,6 +4,7 @@ import DesktopRedirect from "~/scenes/DesktopRedirect"; import DelayedMount from "~/components/DelayedMount"; import FullscreenLoading from "~/components/FullscreenLoading"; import Route from "~/components/ProfiledRoute"; +import env from "~/env"; import useQueryNotices from "~/hooks/useQueryNotices"; import lazyWithRetry from "~/utils/lazyWithRetry"; import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; @@ -25,30 +26,37 @@ export default function Routes() { } > - - - - - + {env.ROOT_SHARE_ID ? ( + + + + + ) : ( + + + + + - - + + - - + + - - - - + + + + + )} ); } diff --git a/app/scenes/Document/Shared.tsx b/app/scenes/Document/Shared.tsx index 756b9eab77ee..d8dfdec38600 100644 --- a/app/scenes/Document/Shared.tsx +++ b/app/scenes/Document/Shared.tsx @@ -95,7 +95,7 @@ function SharedDocumentScene(props: Props) { const [response, setResponse] = React.useState(); const [error, setError] = React.useState(); const { documents } = useStores(); - const { shareId, documentSlug } = props.match.params; + const { shareId = env.ROOT_SHARE_ID, documentSlug } = props.match.params; const documentId = useDocumentId(documentSlug, response); const themeOverride = ["dark", "light"].includes( searchParams.get("theme") || "" @@ -185,7 +185,7 @@ function SharedDocumentScene(props: Props) { title={response.document.title} sidebar={ response.sharedTree?.children.length ? ( - + ) : undefined } > diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts index c32d6abc4092..d71fc03c8476 100644 --- a/app/utils/routeHelpers.ts +++ b/app/utils/routeHelpers.ts @@ -2,6 +2,7 @@ import queryString from "query-string"; import Collection from "~/models/Collection"; import Comment from "~/models/Comment"; import Document from "~/models/Document"; +import env from "~/env"; export function homePath(): string { return "/home"; @@ -115,6 +116,10 @@ export function searchPath( } export function sharedDocumentPath(shareId: string, docPath?: string) { + if (shareId === env.ROOT_SHARE_ID) { + return docPath ? docPath : "/"; + } + return docPath ? `/s/${shareId}${docPath}` : `/s/${shareId}`; } diff --git a/server/migrations/20231101021239-share-domain.js b/server/migrations/20231101021239-share-domain.js new file mode 100644 index 000000000000..4a8a1234144e --- /dev/null +++ b/server/migrations/20231101021239-share-domain.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn("shares", "domain", { + type: Sequelize.STRING, + allowNull: true, + unique: true, + }); + }, + + async down (queryInterface) { + await queryInterface.removeColumn("shares", "domain"); + } +}; diff --git a/server/models/Share.ts b/server/models/Share.ts index 797b3e75c624..b092e9129e2e 100644 --- a/server/models/Share.ts +++ b/server/models/Share.ts @@ -1,3 +1,4 @@ +import { type SaveOptions } from "sequelize"; import { ForeignKey, BelongsTo, @@ -9,14 +10,19 @@ import { Default, AllowNull, Is, + Unique, + BeforeUpdate, } from "sequelize-typescript"; import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers"; +import { ValidationError } from "@server/errors"; import Collection from "./Collection"; import Document from "./Document"; import Team from "./Team"; import User from "./User"; import IdModel from "./base/IdModel"; import Fix from "./decorators/Fix"; +import IsFQDN from "./validators/IsFQDN"; +import Length from "./validators/Length"; @DefaultScope(() => ({ include: [ @@ -88,6 +94,36 @@ class Share extends IdModel { @Column urlId: string | null | undefined; + @Unique + @Length({ max: 255, msg: "domain must be 255 characters or less" }) + @IsFQDN + @Column + domain: string | null; + + // hooks + + @BeforeUpdate + static async checkDomain(model: Share, options: SaveOptions) { + if (!model.domain) { + return model; + } + + model.domain = model.domain.toLowerCase(); + + const count = await Team.count({ + ...options, + where: { + domain: model.domain, + }, + }); + + if (count > 0) { + throw ValidationError("Domain is already in use"); + } + + return model; + } + // getters get isRevoked() { diff --git a/server/models/Team.ts b/server/models/Team.ts index 8f91ccb4ef36..7fb9e81cc592 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -3,6 +3,7 @@ import fs from "fs"; import path from "path"; import { URL } from "url"; import util from "util"; +import { type SaveOptions } from "sequelize"; import { Op } from "sequelize"; import { Column, @@ -19,6 +20,7 @@ import { IsUUID, AllowNull, AfterUpdate, + BeforeUpdate, } from "sequelize-typescript"; import { TeamPreferenceDefaults } from "@shared/constants"; import { @@ -28,12 +30,14 @@ import { } from "@shared/types"; import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains"; import env from "@server/env"; +import { ValidationError } from "@server/errors"; import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask"; import parseAttachmentIds from "@server/utils/parseAttachmentIds"; import Attachment from "./Attachment"; import AuthenticationProvider from "./AuthenticationProvider"; import Collection from "./Collection"; import Document from "./Document"; +import Share from "./Share"; import TeamDomain from "./TeamDomain"; import User from "./User"; import ParanoidModel from "./base/ParanoidModel"; @@ -328,6 +332,28 @@ class Team extends ParanoidModel { // hooks + @BeforeUpdate + static async checkDomain(model: Team, options: SaveOptions) { + if (!model.domain) { + return model; + } + + model.domain = model.domain.toLowerCase(); + + const count = await Share.count({ + ...options, + where: { + domain: model.domain, + }, + }); + + if (count > 0) { + throw ValidationError("Domain is already in use"); + } + + return model; + } + @AfterUpdate static deletePreviousAvatar = async (model: Team) => { if ( diff --git a/server/presenters/env.ts b/server/presenters/env.ts index ce90e34fec33..30fc606a2f5e 100644 --- a/server/presenters/env.ts +++ b/server/presenters/env.ts @@ -6,7 +6,10 @@ import { Integration } from "@server/models"; // do not add anything here that should be a secret or password export default function present( env: Environment, - analytics?: Integration | null + options: { + analytics?: Integration | null; + rootShareId?: string | null; + } = {} ): PublicEnv { return { URL: env.URL.replace(/\/$/, ""), @@ -29,9 +32,11 @@ export default function present( RELEASE: process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined, APP_NAME: env.APP_NAME, + ROOT_SHARE_ID: options.rootShareId || undefined, + analytics: { - service: analytics?.service, - settings: analytics?.settings, + service: options.analytics?.service, + settings: options.analytics?.settings, }, }; } diff --git a/server/routes/app.ts b/server/routes/app.ts index 01a91429e0e7..1a895a735e0b 100644 --- a/server/routes/app.ts +++ b/server/routes/app.ts @@ -54,6 +54,7 @@ export const renderApp = async ( description?: string; canonical?: string; shortcutIcon?: string; + rootShareId?: string; analytics?: Integration | null; } = {} ) => { @@ -72,7 +73,7 @@ export const renderApp = async ( const page = await readIndexFile(); const environment = ` `; @@ -106,7 +107,10 @@ export const renderApp = async ( }; export const renderShare = async (ctx: Context, next: Next) => { - const { shareId, documentSlug } = ctx.params; + const rootShareId = ctx.state.rootShare?.id; + const shareId = rootShareId ?? ctx.params.shareId; + const documentSlug = ctx.params.documentSlug; + // Find the share record if publicly published so that the document title // can be be returned in the server-rendered HTML. This allows it to appear in // unfurls with more reliablity @@ -159,6 +163,7 @@ export const renderShare = async (ctx: Context, next: Next) => { ? team.avatarUrl : undefined, analytics, + rootShareId, canonical: share ? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}` : undefined, diff --git a/server/routes/index.ts b/server/routes/index.ts index 8c3b072fff7d..5a4d87df3798 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -6,11 +6,13 @@ import compress from "koa-compress"; import Router from "koa-router"; import send from "koa-send"; import userAgent, { UserAgentContext } from "koa-useragent"; +import { Op } from "sequelize"; import { languages } from "@shared/i18n"; import { IntegrationType } from "@shared/types"; +import { parseDomain } from "@shared/utils/domains"; import env from "@server/env"; import { NotFoundError } from "@server/errors"; -import { Integration } from "@server/models"; +import { Integration, Share } from "@server/models"; import { opensearchResponse } from "@server/utils/opensearch"; import { getTeamFromContext } from "@server/utils/passport"; import { robotsResponse } from "@server/utils/robots"; @@ -137,6 +139,25 @@ router.get("*", async (ctx, next) => { return; } + const isCustomDomain = parseDomain(ctx.host).custom; + const isDevelopment = env.ENVIRONMENT === "development"; + if (!team && (isDevelopment || (isCustomDomain && env.isCloudHosted))) { + const share = await Share.unscoped().findOne({ + where: { + domain: ctx.hostname, + published: true, + revokedAt: { + [Op.is]: null, + }, + }, + }); + + if (share) { + ctx.state.rootShare = share; + return renderShare(ctx, next); + } + } + const analytics = team ? await Integration.findOne({ where: { diff --git a/server/utils/passport.ts b/server/utils/passport.ts index 6d8aef191662..bf9836c06223 100644 --- a/server/utils/passport.ts +++ b/server/utils/passport.ts @@ -116,6 +116,8 @@ export async function getTeamFromContext(ctx: Context) { } else { team = await Team.findOne(); } + } else if (ctx.state.rootShare) { + team = await Team.findByPk(ctx.state.rootShare.teamId); } else if (domain.custom) { team = await Team.findOne({ where: { domain: domain.host } }); } else if (domain.teamSubdomain) { diff --git a/shared/types.ts b/shared/types.ts index 5f72a9635f23..b5234cbeb4fc 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -60,6 +60,7 @@ export type PublicEnv = { GOOGLE_ANALYTICS_ID: string | undefined; RELEASE: string | undefined; APP_NAME: string; + ROOT_SHARE_ID?: string; analytics: { service?: IntegrationService; settings?: IntegrationSettings; From 0f072acfd91cf9e63582754245865a3991fec39d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 1 Nov 2023 23:14:45 -0400 Subject: [PATCH 041/241] fix: Guard undefined ctx.state --- server/routes/app.ts | 2 +- server/utils/passport.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routes/app.ts b/server/routes/app.ts index 1a895a735e0b..23effcb7e65c 100644 --- a/server/routes/app.ts +++ b/server/routes/app.ts @@ -107,7 +107,7 @@ export const renderApp = async ( }; export const renderShare = async (ctx: Context, next: Next) => { - const rootShareId = ctx.state.rootShare?.id; + const rootShareId = ctx.state?.rootShare?.id; const shareId = rootShareId ?? ctx.params.shareId; const documentSlug = ctx.params.documentSlug; diff --git a/server/utils/passport.ts b/server/utils/passport.ts index bf9836c06223..a28eaf35baaa 100644 --- a/server/utils/passport.ts +++ b/server/utils/passport.ts @@ -116,7 +116,7 @@ export async function getTeamFromContext(ctx: Context) { } else { team = await Team.findOne(); } - } else if (ctx.state.rootShare) { + } else if (ctx.state?.rootShare) { team = await Team.findByPk(ctx.state.rootShare.teamId); } else if (domain.custom) { team = await Team.findOne({ where: { domain: domain.host } }); From f0bf60eb40ce9989e5b5d4686b0a08298917d4f2 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 1 Nov 2023 23:17:54 -0400 Subject: [PATCH 042/241] Add graceful redirect from old share paths --- app/routes/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 17dcd47c27ad..3f90c125b879 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -30,6 +30,12 @@ export default function Routes() { + + ) : ( From 1b733398005327a7f75df91e6f174ac66dd8c7e1 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 1 Nov 2023 23:20:41 -0400 Subject: [PATCH 043/241] fix: Link on 'Not Found' page for root share leads to custom domain landing --- app/scenes/Error404.tsx | 3 ++- app/utils/routeHelpers.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/scenes/Error404.tsx b/app/scenes/Error404.tsx index a78c0c37cf91..a6a9adc7083b 100644 --- a/app/scenes/Error404.tsx +++ b/app/scenes/Error404.tsx @@ -3,6 +3,7 @@ import { useTranslation, Trans } from "react-i18next"; import { Link } from "react-router-dom"; import Empty from "~/components/Empty"; import Scene from "~/components/Scene"; +import { homePath } from "~/utils/routeHelpers"; const Error404 = () => { const { t } = useTranslation(); @@ -12,7 +13,7 @@ const Error404 = () => { We were unable to find the page you’re looking for. Go to the{" "} - homepage? + homepage? diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts index d71fc03c8476..840330a97cb0 100644 --- a/app/utils/routeHelpers.ts +++ b/app/utils/routeHelpers.ts @@ -5,7 +5,7 @@ import Document from "~/models/Document"; import env from "~/env"; export function homePath(): string { - return "/home"; + return env.ROOT_SHARE_ID ? "/" : "/home"; } export function draftsPath(): string { From a48d8fac889566146ed8508c87497cf1d07fd130 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 1 Nov 2023 23:45:41 -0400 Subject: [PATCH 044/241] Return correct canonical url for share with domain --- server/models/Share.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/models/Share.ts b/server/models/Share.ts index b092e9129e2e..1f13c2f647e8 100644 --- a/server/models/Share.ts +++ b/server/models/Share.ts @@ -14,6 +14,7 @@ import { BeforeUpdate, } from "sequelize-typescript"; import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers"; +import env from "@server/env"; import { ValidationError } from "@server/errors"; import Collection from "./Collection"; import Document from "./Document"; @@ -131,6 +132,11 @@ class Share extends IdModel { } get canonicalUrl() { + if (this.domain) { + const url = new URL(env.URL); + return `${url.protocol}//${this.domain}${url.port ? `:${url.port}` : ""}`; + } + return this.urlId ? `${this.team.url}/s/${this.urlId}` : `${this.team.url}/s/${this.id}`; From b2ad6ca9bcab72aae19970b836c591da105cecdf Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 1 Nov 2023 23:52:18 -0400 Subject: [PATCH 045/241] Refactor to middleware, support old routes --- server/middlewares/shareDomains.ts | 27 ++++++++++++++++++++++ server/routes/index.ts | 36 +++++++++--------------------- 2 files changed, 37 insertions(+), 26 deletions(-) create mode 100644 server/middlewares/shareDomains.ts diff --git a/server/middlewares/shareDomains.ts b/server/middlewares/shareDomains.ts new file mode 100644 index 000000000000..f846fcdddb3a --- /dev/null +++ b/server/middlewares/shareDomains.ts @@ -0,0 +1,27 @@ +import { Context, Next } from "koa"; +import { Op } from "sequelize"; +import { parseDomain } from "@shared/utils/domains"; +import env from "@server/env"; +import { Share } from "@server/models"; + +export default function shareDomains() { + return async function shareDomainsMiddleware(ctx: Context, next: Next) { + const isCustomDomain = parseDomain(ctx.host).custom; + const isDevelopment = env.ENVIRONMENT === "development"; + + if (isDevelopment || (isCustomDomain && env.isCloudHosted)) { + const share = await Share.unscoped().findOne({ + where: { + domain: ctx.hostname, + published: true, + revokedAt: { + [Op.is]: null, + }, + }, + }); + ctx.state.rootShare = share; + } + + return next(); + }; +} diff --git a/server/routes/index.ts b/server/routes/index.ts index 5a4d87df3798..4d41088a2513 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -6,13 +6,12 @@ import compress from "koa-compress"; import Router from "koa-router"; import send from "koa-send"; import userAgent, { UserAgentContext } from "koa-useragent"; -import { Op } from "sequelize"; import { languages } from "@shared/i18n"; import { IntegrationType } from "@shared/types"; -import { parseDomain } from "@shared/utils/domains"; import env from "@server/env"; import { NotFoundError } from "@server/errors"; -import { Integration, Share } from "@server/models"; +import shareDomains from "@server/middlewares/shareDomains"; +import { Integration } from "@server/models"; import { opensearchResponse } from "@server/utils/opensearch"; import { getTeamFromContext } from "@server/utils/passport"; import { robotsResponse } from "@server/utils/robots"; @@ -125,12 +124,16 @@ router.get("/opensearch.xml", (ctx) => { ctx.body = opensearchResponse(ctx.request.URL.origin); }); -router.get("/s/:shareId", renderShare); -router.get("/s/:shareId/doc/:documentSlug", renderShare); -router.get("/s/:shareId/*", renderShare); +router.get("/s/:shareId", shareDomains(), renderShare); +router.get("/s/:shareId/doc/:documentSlug", shareDomains(), renderShare); +router.get("/s/:shareId/*", shareDomains(), renderShare); // catch all for application -router.get("*", async (ctx, next) => { +router.get("*", shareDomains(), async (ctx, next) => { + if (ctx.state?.rootShare) { + return renderShare(ctx, next); + } + const team = await getTeamFromContext(ctx); // Redirect all requests to custom domain if one is set @@ -139,25 +142,6 @@ router.get("*", async (ctx, next) => { return; } - const isCustomDomain = parseDomain(ctx.host).custom; - const isDevelopment = env.ENVIRONMENT === "development"; - if (!team && (isDevelopment || (isCustomDomain && env.isCloudHosted))) { - const share = await Share.unscoped().findOne({ - where: { - domain: ctx.hostname, - published: true, - revokedAt: { - [Op.is]: null, - }, - }, - }); - - if (share) { - ctx.state.rootShare = share; - return renderShare(ctx, next); - } - } - const analytics = team ? await Integration.findOne({ where: { From c769a95f6558d7a5c18577d715fbb1251620c3a3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 4 Nov 2023 15:21:47 -0400 Subject: [PATCH 046/241] API: Add endpoint to check custom domain resolution (#6110) --- server/routes/api/urls/schema.ts | 8 +++ server/routes/api/urls/urls.test.ts | 37 +++++++++++++ server/routes/api/urls/urls.ts | 64 +++++++++++++++++++++- shared/i18n/locales/en_US/translation.json | 1 + 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/server/routes/api/urls/schema.ts b/server/routes/api/urls/schema.ts index d531e427b773..f9ecf4866bae 100644 --- a/server/routes/api/urls/schema.ts +++ b/server/routes/api/urls/schema.ts @@ -34,3 +34,11 @@ export const UrlsUnfurlSchema = BaseSchema.extend({ }); export type UrlsUnfurlReq = z.infer; + +export const UrlsCheckCnameSchema = BaseSchema.extend({ + body: z.object({ + hostname: z.string(), + }), +}); + +export type UrlsCheckCnameReq = z.infer; diff --git a/server/routes/api/urls/urls.test.ts b/server/routes/api/urls/urls.test.ts index e7495b93fb48..69d3c93775bf 100644 --- a/server/routes/api/urls/urls.test.ts +++ b/server/routes/api/urls/urls.test.ts @@ -4,6 +4,19 @@ import { buildDocument, buildUser } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; import resolvers from "@server/utils/unfurl"; +jest.mock("dns", () => ({ + resolveCname: ( + input: string, + callback: (err: Error | null, addresses: string[]) => void + ) => { + if (input.includes("valid.custom.domain")) { + callback(null, ["secure.outline.dev"]); + } else { + callback(null, []); + } + }, +})); + jest .spyOn(resolvers.Iframely, "unfurl") .mockImplementation(async (_: string) => false); @@ -204,3 +217,27 @@ describe("#urls.unfurl", () => { expect(res.status).toEqual(204); }); }); + +describe("#urls.validateCustomDomain", () => { + it("should succeed with custom domain pointing at server", async () => { + const user = await buildUser(); + const res = await server.post("/api/urls.validateCustomDomain", { + body: { + token: user.getJwtToken(), + hostname: "valid.custom.domain", + }, + }); + expect(res.status).toEqual(200); + }); + + it("should fail with another domain", async () => { + const user = await buildUser(); + const res = await server.post("/api/urls.validateCustomDomain", { + body: { + token: user.getJwtToken(), + hostname: "google.com", + }, + }); + expect(res.status).toEqual(400); + }); +}); diff --git a/server/routes/api/urls/urls.ts b/server/routes/api/urls/urls.ts index 00154a119065..25b7336be0dd 100644 --- a/server/routes/api/urls/urls.ts +++ b/server/routes/api/urls/urls.ts @@ -1,5 +1,6 @@ +import dns from "dns"; import Router from "koa-router"; -import { parseDomain } from "@shared/utils/domains"; +import { getBaseDomain, parseDomain } from "@shared/utils/domains"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import parseMentionUrl from "@shared/utils/parseMentionUrl"; import { isInternalUrl } from "@shared/utils/urls"; @@ -7,7 +8,7 @@ import { NotFoundError, ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import validate from "@server/middlewares/validate"; -import { Document, User } from "@server/models"; +import { Document, Share, Team, User } from "@server/models"; import { authorize } from "@server/policies"; import { presentDocument, presentMention } from "@server/presenters/unfurls"; import presentUnfurl from "@server/presenters/unfurls/unfurl"; @@ -84,4 +85,63 @@ router.post( } ); +router.post( + "urls.validateCustomDomain", + rateLimiter(RateLimiterStrategy.OneHundredPerHour), + auth(), + validate(T.UrlsCheckCnameSchema), + async (ctx: APIContext) => { + const { hostname } = ctx.input.body; + + const [team, share] = await Promise.all([ + Team.findOne({ + where: { + domain: hostname, + }, + }), + Share.findOne({ + where: { + domain: hostname, + }, + }), + ]); + if (team || share) { + throw ValidationError("Domain is already in use"); + } + + let addresses; + try { + addresses = await new Promise((resolve, reject) => { + dns.resolveCname(hostname, (err, addresses) => { + if (err) { + return reject(err); + } + return resolve(addresses); + }); + }); + } catch (err) { + if (err.code === "ENOTFOUND") { + throw NotFoundError("No CNAME record found"); + } + + throw ValidationError("Invalid domain"); + } + + if (addresses.length === 0) { + throw ValidationError("No CNAME record found"); + } + + const address = addresses[0]; + const likelyValid = address.endsWith(getBaseDomain()); + + if (!likelyValid) { + throw ValidationError("CNAME is not configured correctly"); + } + + ctx.body = { + success: true, + }; + } +); + export default router; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index a3fb56f09c79..287f4c9dc38f 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -573,6 +573,7 @@ "Custom link": "Custom link", "The document will be accessible at <2>{{url}}": "The document will be accessible at <2>{{url}}", "More options": "More options", + "Custom domain": "Custom domain", "Close": "Close", "{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.", "Are you sure you want to delete the {{ documentTitle }} template?": "Are you sure you want to delete the {{ documentTitle }} template?", From ec79cab8b8c8ffca2580e1d08e21393e7f86ecde Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 4 Nov 2023 21:50:45 -0400 Subject: [PATCH 047/241] fix: Uncaught error in JSZip file reading crashes worker process. closes #6109 --- server/index.ts | 7 +++++++ shared/i18n/locales/en_US/translation.json | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/server/index.ts b/server/index.ts index c4d34acfe4af..a2d7f8493ce6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -164,6 +164,13 @@ async function start(id: number, disconnect: () => void) { ShutdownHelper.add("metrics", ShutdownOrder.last, () => Metrics.flush()); + // Handle uncaught promise rejections + process.on("unhandledRejection", (error: Error) => { + Logger.error("Unhandled promise rejection", error, { + stack: error.stack, + }); + }); + // Handle shutdown signals process.once("SIGTERM", () => ShutdownHelper.execute()); process.once("SIGINT", () => ShutdownHelper.execute()); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 287f4c9dc38f..a3fb56f09c79 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -573,7 +573,6 @@ "Custom link": "Custom link", "The document will be accessible at <2>{{url}}": "The document will be accessible at <2>{{url}}", "More options": "More options", - "Custom domain": "Custom domain", "Close": "Close", "{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.", "Are you sure you want to delete the {{ documentTitle }} template?": "Are you sure you want to delete the {{ documentTitle }} template?", From c76aa845f4dffd7a2f724999a863bd2677d44344 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 4 Nov 2023 21:57:13 -0400 Subject: [PATCH 048/241] fix: Protect against view updates after destroyed in async uploads --- shared/editor/commands/insertFiles.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts index a1941d0666a0..0af48d777838 100644 --- a/shared/editor/commands/insertFiles.ts +++ b/shared/editor/commands/insertFiles.ts @@ -81,6 +81,9 @@ const insertFiles = function ( // happening in the background in parallel. uploadFile?.(upload.file) .then(async (src) => { + if (view.isDestroyed) { + return; + } if (upload.isImage) { const newImg = new Image(); newImg.onload = () => { @@ -88,6 +91,9 @@ const insertFiles = function ( if (result === null) { return; } + if (view.isDestroyed) { + return; + } const [from, to] = result; view.dispatch( @@ -115,6 +121,10 @@ const insertFiles = function ( const [from, to] = result; const dimensions = await FileHelper.getVideoDimensions(upload.file); + if (view.isDestroyed) { + return; + } + view.dispatch( view.state.tr .replaceWith( @@ -159,6 +169,10 @@ const insertFiles = function ( // eslint-disable-next-line no-console console.error(error); + if (view.isDestroyed) { + return; + } + // cleanup the placeholder if there is a failure view.dispatch( view.state.tr.setMeta(uploadPlaceholderPlugin, { From 7c319c17c6c1aaa3010d06a9dd1c750a7cd59766 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 5 Nov 2023 09:43:38 -0500 Subject: [PATCH 049/241] fix: Correct user on documents in deleted collection (#6116) --- server/commands/collectionDestroyer.ts | 21 +++++++++++++++-- server/models/Collection.ts | 13 ----------- .../processors/CollectionDeletedProcessor.ts | 7 ++++++ .../queues/processors/CollectionsProcessor.ts | 23 ------------------- 4 files changed, 26 insertions(+), 38 deletions(-) delete mode 100644 server/queues/processors/CollectionsProcessor.ts diff --git a/server/commands/collectionDestroyer.ts b/server/commands/collectionDestroyer.ts index 69180c97c0cd..6d120d2e06c2 100644 --- a/server/commands/collectionDestroyer.ts +++ b/server/commands/collectionDestroyer.ts @@ -1,5 +1,5 @@ -import { Transaction } from "sequelize"; -import { Collection, Event, User } from "@server/models"; +import { Transaction, Op } from "sequelize"; +import { Collection, Document, Event, User } from "@server/models"; type Props = { /** The collection to delete */ @@ -20,6 +20,23 @@ export default async function collectionDestroyer({ }: Props) { await collection.destroy({ transaction }); + await Document.update( + { + lastModifiedById: user.id, + deletedAt: new Date(), + }, + { + transaction, + where: { + teamId: collection.teamId, + collectionId: collection.id, + archivedAt: { + [Op.is]: null, + }, + }, + } + ); + await Event.create( { name: "collections.delete", diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 9b884e9a8b39..3b1543a8baa7 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -20,7 +20,6 @@ import { Default, BeforeValidate, BeforeSave, - AfterDestroy, AfterCreate, HasMany, BelongsToMany, @@ -279,18 +278,6 @@ class Collection extends ParanoidModel { } } - @AfterDestroy - static async onAfterDestroy(model: Collection) { - await Document.destroy({ - where: { - collectionId: model.id, - archivedAt: { - [Op.is]: null, - }, - }, - }); - } - @AfterCreate static async onAfterCreate( model: Collection, diff --git a/server/queues/processors/CollectionDeletedProcessor.ts b/server/queues/processors/CollectionDeletedProcessor.ts index ca003a7c9d65..5f30a60940a1 100644 --- a/server/queues/processors/CollectionDeletedProcessor.ts +++ b/server/queues/processors/CollectionDeletedProcessor.ts @@ -2,12 +2,19 @@ import teamUpdater from "@server/commands/teamUpdater"; import { Team, User } from "@server/models"; import { sequelize } from "@server/storage/database"; import { Event as TEvent, CollectionEvent } from "@server/types"; +import DetachDraftsFromCollectionTask from "../tasks/DetachDraftsFromCollectionTask"; import BaseProcessor from "./BaseProcessor"; export default class CollectionDeletedProcessor extends BaseProcessor { static applicableEvents: TEvent["name"][] = ["collections.delete"]; async perform(event: CollectionEvent) { + await DetachDraftsFromCollectionTask.schedule({ + collectionId: event.collectionId, + actorId: event.actorId, + ip: event.ip, + }); + await sequelize.transaction(async (transaction) => { const team = await Team.findByPk(event.teamId, { rejectOnEmpty: true, diff --git a/server/queues/processors/CollectionsProcessor.ts b/server/queues/processors/CollectionsProcessor.ts deleted file mode 100644 index 121d96336bc5..000000000000 --- a/server/queues/processors/CollectionsProcessor.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { CollectionEvent, Event } from "@server/types"; -import DetachDraftsFromCollectionTask from "../tasks/DetachDraftsFromCollectionTask"; -import BaseProcessor from "./BaseProcessor"; - -export default class CollectionsProcessor extends BaseProcessor { - static applicableEvents: Event["name"][] = ["collections.delete"]; - - async perform(event: CollectionEvent) { - switch (event.name) { - case "collections.delete": - return this.collectionDeleted(event); - default: - } - } - - async collectionDeleted(event: CollectionEvent) { - await DetachDraftsFromCollectionTask.schedule({ - collectionId: event.collectionId, - actorId: event.actorId, - ip: event.ip, - }); - } -} From 733bd39ae4a93b11aa6482846db68a2ad6aa1455 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 5 Nov 2023 12:38:15 -0500 Subject: [PATCH 050/241] fix: Sort nodes correctly in useCollectionTrees. closes #6102 --- app/hooks/useCollectionTrees.ts | 5 ++++- shared/utils/collections.ts | 14 +++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/hooks/useCollectionTrees.ts b/app/hooks/useCollectionTrees.ts index 7d8ed5784253..6ce03bc2439c 100644 --- a/app/hooks/useCollectionTrees.ts +++ b/app/hooks/useCollectionTrees.ts @@ -1,5 +1,6 @@ import * as React from "react"; import { NavigationNode, NavigationNodeType } from "@shared/types"; +import { sortNavigationNodes } from "@shared/utils/collections"; import Collection from "~/models/Collection"; import useStores from "~/hooks/useStores"; @@ -66,7 +67,9 @@ export default function useCollectionTrees(): NavigationNode[] { title: collection.name, url: collection.url, type: NavigationNodeType.Collection, - children: collection.documents || [], + children: collection.documents + ? sortNavigationNodes(collection.documents, collection.sort, true) + : [], parent: null, }; diff --git a/shared/utils/collections.ts b/shared/utils/collections.ts index a400b26979d0..deed689713ce 100644 --- a/shared/utils/collections.ts +++ b/shared/utils/collections.ts @@ -7,25 +7,25 @@ type Sort = { }; export const sortNavigationNodes = ( - documents: NavigationNode[], + nodes: NavigationNode[], sort: Sort, sortChildren = true ): NavigationNode[] => { // "index" field is manually sorted and is represented by the documentStructure // already saved in the database, no further sort is needed if (sort.field === "index") { - return documents; + return nodes; } - const orderedDocs = naturalSort(documents, sort.field, { + const orderedDocs = naturalSort(nodes, sort.field, { direction: sort.direction, }); - return orderedDocs.map((document) => ({ - ...document, + return orderedDocs.map((node) => ({ + ...node, children: sortChildren - ? sortNavigationNodes(document.children, sort, sortChildren) - : document.children, + ? sortNavigationNodes(node.children, sort, sortChildren) + : node.children, })); }; From 9be180d44d334f92f3ab1a3f0ec5a8059460a5ca Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 5 Nov 2023 12:44:53 -0500 Subject: [PATCH 051/241] fix: Incorrect cursor on sortable table header cells --- app/components/Table.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/Table.tsx b/app/components/Table.tsx index 66b00b777589..52d01ef4fd1c 100644 --- a/app/components/Table.tsx +++ b/app/components/Table.tsx @@ -309,6 +309,7 @@ const Head = styled.th` color: ${s("textSecondary")}; font-weight: 500; z-index: 1; + cursor: var(--pointer) !important; :first-child { padding-left: 0; From 7b885470510f86b8b277d65954480149437b2a29 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 5 Nov 2023 13:39:34 -0500 Subject: [PATCH 052/241] Add more fields to shared links management screen --- app/components/Scene.tsx | 17 +++++-- app/models/Share.ts | 4 ++ app/scenes/Settings/Shares.tsx | 25 +++++---- .../Settings/components/SharesTable.tsx | 51 ++++++++++++------- server/presenters/share.ts | 1 + shared/i18n/locales/en_US/translation.json | 2 + 6 files changed, 70 insertions(+), 30 deletions(-) diff --git a/app/components/Scene.tsx b/app/components/Scene.tsx index 3e543bb41e18..3c28c38b8bca 100644 --- a/app/components/Scene.tsx +++ b/app/components/Scene.tsx @@ -5,12 +5,21 @@ import Header from "~/components/Header"; import PageTitle from "~/components/PageTitle"; type Props = { + /** An icon to display in the header when content has scrolled past the title */ icon?: React.ReactNode; + /** The title of the scene */ title?: React.ReactNode; + /** The title of the scene, as text – only required if the title prop is not plain text */ textTitle?: string; + /** A component to display on the left side of the header */ left?: React.ReactNode; + /** A component to display on the right side of the header */ actions?: React.ReactNode; + /** Whether to center the content horizontally with the standard maximum width (default: true) */ centered?: boolean; + /** Whether to use the full width of the screen (default: false) */ + wide?: boolean; + /** The content of the scene */ children?: React.ReactNode; }; @@ -22,8 +31,9 @@ const Scene: React.FC = ({ left, children, centered, + wide, }: Props) => ( - +
= ({ actions={actions} left={left} /> - {centered !== false ? ( + {centered !== false && wide !== true ? ( {children} ) : ( children @@ -47,8 +57,9 @@ const Scene: React.FC = ({ ); -const FillWidth = styled.div` +const FillWidth = styled.div<{ $wide?: boolean }>` width: 100%; + ${(props) => props.$wide && `padding: 0px 32px 16px;`} `; export default Scene; diff --git a/app/models/Share.ts b/app/models/Share.ts index ea1d7b850a37..75d5735a3654 100644 --- a/app/models/Share.ts +++ b/app/models/Share.ts @@ -27,6 +27,10 @@ class Share extends Model { @observable urlId: string; + @Field + @observable + domain: string; + @observable documentTitle: string; diff --git a/app/scenes/Settings/Shares.tsx b/app/scenes/Settings/Shares.tsx index 0a69eb63662c..2ee133a83d91 100644 --- a/app/scenes/Settings/Shares.tsx +++ b/app/scenes/Settings/Shares.tsx @@ -6,6 +6,7 @@ import { useTranslation, Trans } from "react-i18next"; import { Link } from "react-router-dom"; import { PAGINATION_SYMBOL } from "~/stores/base/Store"; import Share from "~/models/Share"; +import Fade from "~/components/Fade"; import Heading from "~/components/Heading"; import Notice from "~/components/Notice"; import Scene from "~/components/Scene"; @@ -67,7 +68,7 @@ function Shares() { }, [shares.orderedData, shareIds]); return ( - }> + } wide> {t("Shared Links")} {can.update && !canShareDocuments && ( @@ -93,15 +94,19 @@ function Shares() { - + {data.length ? ( + + + + ) : null} ); } diff --git a/app/scenes/Settings/components/SharesTable.tsx b/app/scenes/Settings/components/SharesTable.tsx index 52032c0e0a58..e39af90b985b 100644 --- a/app/scenes/Settings/components/SharesTable.tsx +++ b/app/scenes/Settings/components/SharesTable.tsx @@ -8,6 +8,7 @@ import Avatar from "~/components/Avatar"; import Flex from "~/components/Flex"; import TableFromParams from "~/components/TableFromParams"; import Time from "~/components/Time"; +import Tooltip from "~/components/Tooltip"; import ShareMenu from "~/menus/ShareMenu"; type Props = Omit, "columns"> & { @@ -15,9 +16,10 @@ type Props = Omit, "columns"> & { canManage: boolean; }; -function SharesTable({ canManage, ...rest }: Props) { +function SharesTable({ canManage, data, ...rest }: Props) { const { t } = useTranslation(); const theme = useTheme(); + const hasDomain = data.some((share) => share.domain); const columns = React.useMemo( () => @@ -29,23 +31,28 @@ function SharesTable({ canManage, ...rest }: Props) { disableSortBy: true, Cell: observer(({ value }: { value: string }) => <>{value}), }, + { + id: "who", + Header: t("Shared by"), + accessor: "createdById", + disableSortBy: true, + Cell: observer( + ({ row }: { value: string; row: { original: Share } }) => ( + + {row.original.createdBy && ( + + )} + {row.original.createdBy.name} + + ) + ), + }, { id: "createdAt", Header: t("Date shared"), accessor: "createdAt", - Cell: observer( - ({ value, row }: { value: string; row: { original: Share } }) => - value ? ( - - {row.original.createdBy && ( - - )} - - ) : null + Cell: observer(({ value }: { value: string }) => + value ?