From cf67ad0b4fd46f5eeec0e59da4986d4bf4da1dfd Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Thu, 30 Jul 2026 15:58:49 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20mobile=20UI=20overhaul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt the whole webmail UI to the native shell in one pass: bottom navigation bar, mobile thread header/toolbar, swipe actions on thread rows, pull-to-refresh, drawer, a BlockNote formatting toolbar pinned above the keyboard, a dedicated icon set, haptics and keyboard handling. Everything branches on isNativePlatform() (with a dev-only DEV_FAKE_NATIVE escape hatch for desktop devtools) so the desktop web UI keeps its existing layout. Dependency bumps (BlockNote, Cunningham, Capacitor plugins) support these components. --- .../android/app/capacitor.build.gradle | 1 + .../res/values/ic_launcher_background.xml | 4 + .../android/capacitor.settings.gradle | 3 + src/frontend/assets/logo-dark.png | Bin 0 -> 94062 bytes src/frontend/assets/logo.png | Bin 0 -> 94062 bytes src/frontend/index.html | 2 +- src/frontend/ios/App/CapApp-SPM/Package.swift | 2 + src/frontend/package-lock.json | 2282 +++++++++-------- src/frontend/package.json | 31 +- src/frontend/public/locales/common/en-US.json | 15 +- src/frontend/public/locales/common/fr-FR.json | 15 +- src/frontend/src/bootstrap.tsx | 5 + .../blocknote-view-field/_index.scss | 7 + .../blocknote/blocknote-view-field/index.tsx | 6 + .../blocknote/hooks/use-base64-composer.tsx | 3 +- .../blocknote/image-upload-button/index.tsx | 59 +- .../editing-behavior.test.ts | 316 ++- .../editing-behavior.ts | 262 +- .../inline-template-variable/index.tsx | 114 +- .../message-template-block/index.tsx | 63 + .../blocknote/mobile-toolbar/_index.scss | 271 ++ .../blocknote/mobile-toolbar/buttons.tsx | 256 ++ .../mobile-toolbar/drawer-context.ts | 32 + .../blocknote/mobile-toolbar/format-panel.tsx | 217 ++ .../blocknote/mobile-toolbar/hooks.ts | 213 ++ .../blocknote/mobile-toolbar/index.tsx | 222 ++ .../blocknote/mobile-toolbar/link-editor.tsx | 89 + .../blocknote/signature-block/index.tsx | 105 +- .../features/blocknote/toolbar-separator.tsx | 3 + .../src/features/blocknote/toolbar.tsx | 32 +- src/frontend/src/features/blocknote/utils.ts | 50 +- .../components/message-composer/index.tsx | 3 +- .../forms/components/message-form/index.tsx | 12 +- .../search-filters-form/_index.scss | 38 + .../components/search-filters-form/index.tsx | 49 +- .../forms/components/search-input/_index.scss | 4 + .../forms/components/search-input/index.tsx | 101 +- .../hooks/use-compact-tab-drill-down.test.tsx | 122 + .../hooks/use-compact-tab-drill-down.ts | 64 + .../mailboxes-view/mailbox-data-grid.tsx | 8 +- .../signatures-view/signature-data-grid.tsx | 6 +- .../components/labels-widget/_index.scss | 36 + .../components/labels-widget/index.tsx | 199 +- .../components/mailbox-actions/index.tsx | 65 +- .../components/mailbox-labels/_index.scss | 2 +- .../components/label-form-modal/index.tsx | 12 +- .../components/label-item/index.tsx | 12 +- .../components/mailbox-labels/index.tsx | 8 +- .../components/mailbox-list/index.tsx | 38 +- .../compose-autoreply-action.tsx | 21 +- .../devices-view/user-devices-grid.tsx | 65 +- .../mailbox-settings/imports-view/_index.scss | 15 +- .../imports-view/import-new-title.tsx | 12 +- .../imports-view/imports-data-grid.tsx | 10 +- .../create-integration-action.tsx | 17 +- .../compose-template-action.tsx | 17 +- .../modal-compose-integration/index.tsx | 5 +- .../tags-selector.tsx | 37 +- .../modal-compose-mailbox-autoreply/index.tsx | 5 +- .../modal-compose-mailbox-signature/index.tsx | 5 +- .../modal-compose-template/index.tsx | 5 +- .../modal-mailbox-settings/_index.scss | 6 +- .../access-tab/index.tsx | 12 +- .../autoreplies-tab/index.tsx | 2 +- .../imports-tab/index.tsx | 12 +- .../modal-mailbox-settings/index.tsx | 27 +- .../integrations-tab/index.tsx | 2 +- .../message-templates-tab/index.tsx | 2 +- .../signatures-tab/index.tsx | 2 +- .../compose-signature-action.tsx | 17 +- .../components/main/header/_index.scss | 71 +- .../components/main/header/anonymous.tsx | 29 +- .../components/main/header/authenticated.tsx | 105 +- .../layouts/components/main/index.tsx | 8 +- .../components/main/layout/_index.scss | 25 +- .../components/main/left-panel/_index.scss | 16 +- .../components/mobile/bottom-bar/_index.scss | 22 + .../components/mobile/bottom-bar/index.tsx | 30 + .../mobile/filter-compose-bar/_index.scss | 28 + .../mobile/filter-compose-bar/index.tsx | 35 + .../mobile/thread-header/_index.scss | 31 + .../components/mobile/thread-header/index.tsx | 39 + .../mobile/thread-toolbar/_index.scss | 73 + .../mobile/thread-toolbar/index.tsx | 303 +++ .../components/thread-panel/_index.scss | 169 ++ .../components/thread-item/_index.scss | 234 +- .../components/thread-item/index.tsx | 285 +- .../thread-item/thread-action-bar/index.tsx | 0 .../thread-item/thread-drag-preview.tsx | 14 +- .../thread-item/thread-item-swipe.tsx | 211 ++ .../components/thread-panel-filter.test.tsx | 184 ++ .../components/thread-panel-filter.tsx | 158 +- .../components/thread-panel-header.tsx | 566 ++-- .../hooks/use-selected-filters.ts | 65 + .../layouts/components/thread-panel/index.tsx | 96 +- .../components/thread-view/_index.scss | 156 +- .../message-reply-form/draft-actions-menu.tsx | 10 +- .../thread-accesses-widget/index.tsx | 34 +- .../components/thread-action-bar/_index.scss | 9 +- .../components/thread-action-bar/index.tsx | 145 +- .../components/thread-event/index.tsx | 40 +- .../components/thread-message/_index.scss | 11 + .../components/thread-message/index.tsx | 20 + .../thread-message/thread-message-actions.tsx | 22 +- .../thread-message/thread-message-footer.tsx | 8 +- .../layouts/components/thread-view/index.tsx | 154 +- .../components/thread-view/provider.tsx | 23 +- .../features/message/use-compose-message.ts | 29 + .../src/features/message/use-leave-thread.tsx | 53 + .../message/use-thread-row-actions.ts | 87 + .../src/features/message/use-thread-unread.ts | 27 + src/frontend/src/features/native/haptics.ts | 27 + src/frontend/src/features/native/keyboard.ts | 21 + src/frontend/src/features/native/platform.ts | 8 +- .../pull-to-refresh-indicator/_index.scss | 45 + .../pull-to-refresh-indicator/index.tsx | 22 + .../features/native/use-pull-to-refresh.ts | 169 ++ .../features/providers/thread-selection.tsx | 12 + .../features/ui/components/drawer/_index.scss | 121 + .../features/ui/components/drawer/index.tsx | 98 + .../features/ui/components/icon/_index.scss | 20 + .../ui/components/icon/assets/assign.svg | 3 + .../ui/components/icon/assets/at-sign.svg | 3 + .../components/icon/assets/circle-dashed.svg | 3 + .../ui/components/icon/assets/dns.svg | 3 + .../icon/assets/filter-notification.svg | 3 + .../ui/components/icon/assets/fold.svg | 3 + .../ui/components/icon/assets/forward.svg | 3 + .../ui/components/icon/assets/inbox.svg | 3 + .../ui/components/icon/assets/mail-open.svg | 3 + .../ui/components/icon/assets/mail-out.svg | 4 + .../ui/components/icon/assets/mail-plus.svg | 4 + .../ui/components/icon/assets/mail-unread.svg | 4 + .../ui/components/icon/assets/reply-all.svg | 3 + .../ui/components/icon/assets/reply.svg | 3 + .../ui/components/icon/assets/sign.svg | 4 + .../ui/components/icon/assets/signature.svg | 3 + .../ui/components/icon/assets/tag-fill.svg | 4 + .../ui/components/icon/assets/tag.svg | 3 + .../ui/components/icon/assets/text-wrap.svg | 3 + .../ui/components/icon/assets/unfold.svg | 3 + .../ui/components/icon/index.test.tsx | 167 ++ .../src/features/ui/components/icon/index.tsx | 198 ++ .../ui/components/toaster/_index.scss | 46 +- .../features/ui/components/toaster/index.tsx | 16 +- .../src/hooks/use-drag-gesture.test.ts | 76 + src/frontend/src/hooks/use-drag-gesture.ts | 210 ++ .../src/hooks/use-long-press.test.tsx | 113 + src/frontend/src/hooks/use-long-press.ts | 96 + .../src/hooks/use-refresh-feedback.ts | 68 + .../src/hooks/use-swipe-actions.test.tsx | 236 ++ src/frontend/src/hooks/use-swipe-actions.ts | 303 +++ src/frontend/src/routes/__root.tsx | 10 +- src/frontend/src/routes/index.tsx | 11 +- .../src/routes/mailbox/$mailboxId/index.tsx | 69 +- .../mailbox/$mailboxId/thread/$threadId.tsx | 23 +- src/frontend/src/styles/globals.scss | 50 +- src/frontend/src/styles/main.scss | 8 + src/frontend/src/styles/reset.scss | 2 +- src/frontend/src/styles/utils/_controls.scss | 18 + src/frontend/src/styles/utils/_index.scss | 1 + src/frontend/src/styles/views/_index.scss | 4 +- 162 files changed, 9391 insertions(+), 2322 deletions(-) create mode 100644 src/frontend/android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 src/frontend/assets/logo-dark.png create mode 100644 src/frontend/assets/logo.png create mode 100644 src/frontend/src/features/blocknote/mobile-toolbar/_index.scss create mode 100644 src/frontend/src/features/blocknote/mobile-toolbar/buttons.tsx create mode 100644 src/frontend/src/features/blocknote/mobile-toolbar/drawer-context.ts create mode 100644 src/frontend/src/features/blocknote/mobile-toolbar/format-panel.tsx create mode 100644 src/frontend/src/features/blocknote/mobile-toolbar/hooks.ts create mode 100644 src/frontend/src/features/blocknote/mobile-toolbar/index.tsx create mode 100644 src/frontend/src/features/blocknote/mobile-toolbar/link-editor.tsx create mode 100644 src/frontend/src/features/blocknote/toolbar-separator.tsx create mode 100644 src/frontend/src/features/hooks/use-compact-tab-drill-down.test.tsx create mode 100644 src/frontend/src/features/hooks/use-compact-tab-drill-down.ts create mode 100644 src/frontend/src/features/layouts/components/mobile/bottom-bar/_index.scss create mode 100644 src/frontend/src/features/layouts/components/mobile/bottom-bar/index.tsx create mode 100644 src/frontend/src/features/layouts/components/mobile/filter-compose-bar/_index.scss create mode 100644 src/frontend/src/features/layouts/components/mobile/filter-compose-bar/index.tsx create mode 100644 src/frontend/src/features/layouts/components/mobile/thread-header/_index.scss create mode 100644 src/frontend/src/features/layouts/components/mobile/thread-header/index.tsx create mode 100644 src/frontend/src/features/layouts/components/mobile/thread-toolbar/_index.scss create mode 100644 src/frontend/src/features/layouts/components/mobile/thread-toolbar/index.tsx delete mode 100644 src/frontend/src/features/layouts/components/thread-panel/components/thread-item/thread-action-bar/index.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-panel/components/thread-item/thread-item-swipe.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.test.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-panel/hooks/use-selected-filters.ts create mode 100644 src/frontend/src/features/message/use-compose-message.ts create mode 100644 src/frontend/src/features/message/use-leave-thread.tsx create mode 100644 src/frontend/src/features/message/use-thread-row-actions.ts create mode 100644 src/frontend/src/features/message/use-thread-unread.ts create mode 100644 src/frontend/src/features/native/haptics.ts create mode 100644 src/frontend/src/features/native/keyboard.ts create mode 100644 src/frontend/src/features/native/pull-to-refresh-indicator/_index.scss create mode 100644 src/frontend/src/features/native/pull-to-refresh-indicator/index.tsx create mode 100644 src/frontend/src/features/native/use-pull-to-refresh.ts create mode 100644 src/frontend/src/features/ui/components/drawer/_index.scss create mode 100644 src/frontend/src/features/ui/components/drawer/index.tsx create mode 100644 src/frontend/src/features/ui/components/icon/_index.scss create mode 100644 src/frontend/src/features/ui/components/icon/assets/assign.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/at-sign.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/circle-dashed.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/dns.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/filter-notification.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/fold.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/forward.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/inbox.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/mail-open.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/mail-out.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/mail-plus.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/mail-unread.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/reply-all.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/reply.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/sign.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/signature.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/tag-fill.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/tag.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/text-wrap.svg create mode 100644 src/frontend/src/features/ui/components/icon/assets/unfold.svg create mode 100644 src/frontend/src/features/ui/components/icon/index.test.tsx create mode 100644 src/frontend/src/features/ui/components/icon/index.tsx create mode 100644 src/frontend/src/hooks/use-drag-gesture.test.ts create mode 100644 src/frontend/src/hooks/use-drag-gesture.ts create mode 100644 src/frontend/src/hooks/use-long-press.test.tsx create mode 100644 src/frontend/src/hooks/use-long-press.ts create mode 100644 src/frontend/src/hooks/use-refresh-feedback.ts create mode 100644 src/frontend/src/hooks/use-swipe-actions.test.tsx create mode 100644 src/frontend/src/hooks/use-swipe-actions.ts create mode 100644 src/frontend/src/styles/utils/_controls.scss diff --git a/src/frontend/android/app/capacitor.build.gradle b/src/frontend/android/app/capacitor.build.gradle index 6445a534..e90e9e45 100644 --- a/src/frontend/android/app/capacitor.build.gradle +++ b/src/frontend/android/app/capacitor.build.gradle @@ -13,6 +13,7 @@ dependencies { implementation project(':capacitor-browser') implementation project(':capacitor-device') implementation project(':capacitor-filesystem') + implementation project(':capacitor-keyboard') implementation project(':capacitor-push-notifications') implementation project(':capacitor-share') implementation project(':capgo-capacitor-updater') diff --git a/src/frontend/android/app/src/main/res/values/ic_launcher_background.xml b/src/frontend/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..c5d5899f --- /dev/null +++ b/src/frontend/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/src/frontend/android/capacitor.settings.gradle b/src/frontend/android/capacitor.settings.gradle index ce8cc55d..6fa7cfa0 100644 --- a/src/frontend/android/capacitor.settings.gradle +++ b/src/frontend/android/capacitor.settings.gradle @@ -14,6 +14,9 @@ project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/d include ':capacitor-filesystem' project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') + include ':capacitor-push-notifications' project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android') diff --git a/src/frontend/assets/logo-dark.png b/src/frontend/assets/logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a81d72c2a994a8ee29da3d40beae1f649ef2be0b GIT binary patch literal 94062 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 literal 0 HcmV?d00001 diff --git a/src/frontend/assets/logo.png b/src/frontend/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a81d72c2a994a8ee29da3d40beae1f649ef2be0b GIT binary patch literal 94062 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 literal 0 HcmV?d00001 diff --git a/src/frontend/index.html b/src/frontend/index.html index e7a50a0a..0519c48d 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -27,7 +27,7 @@ - + diff --git a/src/frontend/ios/App/CapApp-SPM/Package.swift b/src/frontend/ios/App/CapApp-SPM/Package.swift index 8e83bcf1..a74280af 100644 --- a/src/frontend/ios/App/CapApp-SPM/Package.swift +++ b/src/frontend/ios/App/CapApp-SPM/Package.swift @@ -16,6 +16,7 @@ let package = Package( .package(name: "CapacitorBrowser", path: "../../../node_modules/@capacitor/browser"), .package(name: "CapacitorDevice", path: "../../../node_modules/@capacitor/device"), .package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"), + .package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"), .package(name: "CapacitorPushNotifications", path: "../../../node_modules/@capacitor/push-notifications"), .package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"), .package(name: "CapgoCapacitorUpdater", path: "../../../node_modules/@capgo/capacitor-updater") @@ -30,6 +31,7 @@ let package = Package( .product(name: "CapacitorBrowser", package: "CapacitorBrowser"), .product(name: "CapacitorDevice", package: "CapacitorDevice"), .product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"), + .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"), .product(name: "CapacitorShare", package: "CapacitorShare"), .product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater") diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 4624e860..6256caca 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -8,20 +8,21 @@ "name": "st-messages", "version": "0.9.0", "dependencies": { - "@blocknote/core": "0.51.4", - "@blocknote/mantine": "0.51.4", - "@blocknote/react": "0.51.4", - "@capacitor/app": "8.1.0", - "@capacitor/browser": "8.0.3", - "@capacitor/core": "8.4.0", - "@capacitor/device": "8.0.2", + "@blocknote/core": "0.52.1", + "@blocknote/mantine": "0.52.1", + "@blocknote/react": "0.52.1", + "@capacitor/app": "8.1.1", + "@capacitor/browser": "8.0.4", + "@capacitor/core": "8.4.2", + "@capacitor/device": "8.0.3", "@capacitor/filesystem": "8.1.2", - "@capacitor/push-notifications": "8.1.1", + "@capacitor/keyboard": "8.0.5", + "@capacitor/push-notifications": "8.1.2", "@capacitor/share": "8.0.1", - "@capgo/capacitor-updater": "8.50.1", - "@gouvfr-lasuite/cunningham-react": "4.3.1", + "@capgo/capacitor-updater": "8.51.2", + "@gouvfr-lasuite/cunningham-react": "4.4.0", "@gouvfr-lasuite/drive-sdk": "0.0.2", - "@gouvfr-lasuite/ui-kit": "0.26.0", + "@gouvfr-lasuite/ui-kit": "0.27.0", "@hookform/resolvers": "5.2.2", "@sentry/react": "10.53.1", "@tanstack/react-query": "5.90.20", @@ -29,8 +30,8 @@ "@tanstack/react-router": "1.170.8", "@tanstack/react-router-devtools": "1.167.0", "@tanstack/react-table": "8.21.3", - "@tiptap/core": "^3.27.3", - "@tiptap/pm": "^3.27.3", + "@tiptap/core": "3.29.1", + "@tiptap/pm": "3.29.1", "@viselect/react": "3.9.0", "clsx": "2.1.1", "date-fns": "4.1.0", @@ -47,7 +48,7 @@ "react-icons": "5.7.0", "react-markdown": "10.1.0", "react-resizable-panels": "4.2.2", - "react-toastify": "11.0.5", + "react-toastify": "11.1.0", "sass": "1.97.3", "ts-ics": "2.4.0", "zod": "4.3.6", @@ -2242,9 +2243,9 @@ } }, "node_modules/@blocknote/core": { - "version": "0.51.4", - "resolved": "https://registry.npmjs.org/@blocknote/core/-/core-0.51.4.tgz", - "integrity": "sha512-uR7/BlW6VVlCx/JzfGbkaLGz7O5uI3bu2tbNIdzGTfSloiEp8Qc14if36Dy7DDs+dVWfZgwds0jWiGfE3AzJiw==", + "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@blocknote/core/-/core-0.52.1.tgz", + "integrity": "sha512-a4MgyzS+1Xc6ikEiUgaK1hxUlW9aJFKfyJVQ89OL5kbcnJIRRhIDTIr5ffY7f22wFMT6gjq/Aj+omNof8C1j/A==", "license": "MPL-2.0", "dependencies": { "@emoji-mart/data": "^1.2.1", @@ -2254,9 +2255,7 @@ "@tiptap/core": "^3.13.0", "@tiptap/extension-bold": "^3.13.0", "@tiptap/extension-code": "^3.13.0", - "@tiptap/extension-horizontal-rule": "^3.13.0", "@tiptap/extension-italic": "^3.13.0", - "@tiptap/extension-paragraph": "^3.13.0", "@tiptap/extension-strike": "^3.13.0", "@tiptap/extension-text": "^3.13.0", "@tiptap/extension-underline": "^3.13.0", @@ -2264,27 +2263,69 @@ "@tiptap/pm": "^3.13.0", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", - "lib0": "^0.2.99", + "lib0": "1.0.0-rc.22", "prosemirror-highlight": "^0.15.1", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.3", "prosemirror-transform": "^1.11.0", - "prosemirror-view": "^1.41.4", + "prosemirror-view": "^1.41.4" + }, + "peerDependencies": { + "@y/prosemirror": "^2.0.0-6", + "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.23", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", "yjs": "^13.6.27" + }, + "peerDependenciesMeta": { + "@y/prosemirror": { + "optional": true + }, + "@y/protocols": { + "optional": true + }, + "@y/y": { + "optional": true + }, + "y-prosemirror": { + "optional": true + }, + "y-protocols": { + "optional": true + }, + "yjs": { + "optional": true + } + } + }, + "node_modules/@blocknote/core/node_modules/lib0": { + "version": "1.0.0-rc.22", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-1.0.0-rc.22.tgz", + "integrity": "sha512-KNefJloRQIsWncTF2tIcRqQXSQ7bDRYHwVSUhf1lY2P65Rej4WWFnen6L8L+odJQIo1ZNJGVVjK2WzqB9a+B/g==", + "license": "MIT", + "bin": { + "0ecdsa-generate-keypair": "src/bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "src/bin/gentesthtml.js", + "0serve": "src/bin/0serve.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" } }, "node_modules/@blocknote/mantine": { - "version": "0.51.4", - "resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.51.4.tgz", - "integrity": "sha512-Ka7QjzhgB/T2ekwtXgwa9zIiiixAm4dob3FklJIedu+9j/eO4auokmRnWAhZQmNJsOdE7mQTUmnv6X4k/lHyCQ==", + "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.52.1.tgz", + "integrity": "sha512-evoqFD41Vk0uCgT3CE4Z/7nrlmg6Op3sRtnF9CffiHrsfb3AYVzGlmRv5f2SCKcYwPzBXkLNJMYHZ+tI0ol22g==", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "0.51.4", - "@blocknote/react": "0.51.4", - "react-icons": "^5.5.0" + "@blocknote/core": "^0.52.1", + "@blocknote/react": "^0.52.1" }, "peerDependencies": { "@mantine/core": "^8.3.11 || ^9.0.2", @@ -2294,26 +2335,25 @@ } }, "node_modules/@blocknote/react": { - "version": "0.51.4", - "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.51.4.tgz", - "integrity": "sha512-tWe039t/Gkj9YDoEXruclXQvI+toI0ctcyikbHa8dm3Q6zTgy24+T/P1t1fYSNPBkSnhq+ILz2fLLnCK994qkA==", + "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.52.1.tgz", + "integrity": "sha512-uBSVInj+T6bewINaRZjs6Nad6uL8t++vRLSWgj4cExpL6LuJRM5CPMTzm26eBjgEbC4yO4cSYowsVYh6efgbfw==", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "0.51.4", + "@blocknote/core": "^0.52.1", "@emoji-mart/data": "^1.2.1", "@floating-ui/react": "^0.27.18", - "@floating-ui/utils": "^0.2.10", "@tanstack/react-store": "0.7.7", "@tiptap/core": "^3.13.0", "@tiptap/pm": "^3.13.0", "@tiptap/react": "^3.13.0", - "@types/use-sync-external-store": "1.5.0", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", - "lodash.merge": "^4.6.2", - "react-icons": "^5.5.0", "use-sync-external-store": "1.6.0" }, + "optionalDependencies": { + "@types/use-sync-external-store": "1.5.0" + }, "peerDependencies": { "react": "^18.0 || ^19.0 || >= 19.0.0-rc", "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" @@ -2330,9 +2370,9 @@ } }, "node_modules/@capacitor/app": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz", - "integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.1.tgz", + "integrity": "sha512-xM2ZTX5jK60tFtmjsmJv+Cdj1Qsb6NcavkqmdEnCPQBeya9oKhamZJxYOnQNj1ZvcJM7sWfG6BEtSLx42pisbA==", "license": "MIT", "peerDependencies": { "@capacitor/core": ">=8.0.0" @@ -2481,6 +2521,16 @@ "node": ">=16.0.0" } }, + "node_modules/@capacitor/assets/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@capacitor/assets/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2489,9 +2539,9 @@ "license": "MIT" }, "node_modules/@capacitor/assets/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -2508,6 +2558,21 @@ "node": ">=10" } }, + "node_modules/@capacitor/assets/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@capacitor/assets/node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -2536,6 +2601,16 @@ } } }, + "node_modules/@capacitor/assets/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@capacitor/assets/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2543,6 +2618,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@capacitor/assets/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@capacitor/assets/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -2578,6 +2663,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@capacitor/assets/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@capacitor/assets/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2586,9 +2687,29 @@ "license": "MIT", "engines": { "node": ">=8" + } + }, + "node_modules/@capacitor/assets/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" + } + }, + "node_modules/@capacitor/assets/node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/@capacitor/assets/node_modules/lru-cache": { @@ -2651,6 +2772,31 @@ "node": ">=8" } }, + "node_modules/@capacitor/assets/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@capacitor/assets/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@capacitor/assets/node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -2697,6 +2843,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@capacitor/assets/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@capacitor/assets/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@capacitor/assets/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2712,6 +2878,19 @@ "node": ">=8" } }, + "node_modules/@capacitor/assets/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@capacitor/assets/node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -2748,6 +2927,24 @@ "dev": true, "license": "0BSD" }, + "node_modules/@capacitor/assets/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@capacitor/assets/node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -2779,10 +2976,39 @@ "dev": true, "license": "ISC" }, + "node_modules/@capacitor/assets/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@capacitor/assets/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/@capacitor/browser": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.3.tgz", - "integrity": "sha512-WJWPHEPbweiFoHYmVlCbZf5yrqJ2Rchx2Xvbmd+3Lf+Zkpq3nXBThThY2CF69lYEg1NINGF9BcHThIOEU1gZlQ==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.4.tgz", + "integrity": "sha512-ORZ4u/y+7aMuu6yVuk6SP529fUZyqcbnuHB1q5McpA3o2SYfVoi28iB8rsQjI2ad1qlKJd29ZUuGtspXLBDlWg==", "license": "MIT", "peerDependencies": { "@capacitor/core": ">=8.0.0" @@ -2831,6 +3057,65 @@ "node": ">=18" } }, + "node_modules/@capacitor/cli/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@capacitor/cli/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@capacitor/cli/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@capacitor/cli/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@capacitor/cli/node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@capacitor/cli/node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -2838,27 +3123,43 @@ "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" }, "engines": { - "node": ">=14.14" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@capacitor/cli/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/@capacitor/core": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.0.tgz", - "integrity": "sha512-LrS1xPIrqLtJABBIPDGXxxKmI9OyesrzWw8DiHbxhSC9JoiLUleUAJlX1a0LWIVLRbuY4Szgf9huFeRqYH2SAQ==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.2.tgz", + "integrity": "sha512-fQPRb3JXRaU2pnufDUvqOjhsrYxDQeFRZyaj/sHydIUxD2NxOGHKMpmKUdI+U4OOmKuGZyvRpi7TSJU+7Bjbmg==", "license": "MIT", "dependencies": { "tslib": "^2.1.0" } }, "node_modules/@capacitor/device": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@capacitor/device/-/device-8.0.2.tgz", - "integrity": "sha512-fIqSXnG0s6bz5A/0xFgSXDkbU+Xl65ti80LhucNvLI4kGhJzcNn6SwWVwpXN9SJTOFWXblXknHNppheP8X1frQ==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/device/-/device-8.0.3.tgz", + "integrity": "sha512-rliPCCZwxtjU6200jJNDAsQhioyozV3fGgiP+NgRqtDOI7i+Jdz+DYNwEup+3MGZZ4NEmkuFaBeWLM3T01vJRg==", "license": "MIT", "peerDependencies": { "@capacitor/core": ">=8.0.0" @@ -2886,10 +3187,19 @@ "@capacitor/core": "^8.4.0" } }, + "node_modules/@capacitor/keyboard": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.5.tgz", + "integrity": "sha512-oFXygC4eKYA5l2MdpTR06L2M/4x6e2SLD5yS1T9+UBDKTkzyvhWKEhbYLUaTIBPpLKqlfGudJw1X73S1H9eUzQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/push-notifications": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.1.1.tgz", - "integrity": "sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.1.2.tgz", + "integrity": "sha512-TQl4XxqJfNF+KbSYBFMXYYT5WXUaleVKXlByhh/8+KySMVNjp+l5OqAUoGe/sY1IAnGXEuUeCjA/WLZEVvGwrA==", "license": "MIT", "peerDependencies": { "@capacitor/core": ">=8.0.0" @@ -2911,9 +3221,9 @@ "license": "ISC" }, "node_modules/@capgo/capacitor-updater": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@capgo/capacitor-updater/-/capacitor-updater-8.50.1.tgz", - "integrity": "sha512-hbmTokcq/Gh9tDeCNP7mlSs3TE8axF/LXqOZqBeDoAh+4/i0PH9n1sjBaI3jhff5EwpkPsw6jp9Y8QCTTPhdmg==", + "version": "8.51.2", + "resolved": "https://registry.npmjs.org/@capgo/capacitor-updater/-/capacitor-updater-8.51.2.tgz", + "integrity": "sha512-pV1pTUl30dHLjgSHoHTffCo2cIEbn1MfdMz6Olpqh9K1dkr/WnRfezdvngBBPXo5X1OFSxyu7DFAon8hdnGz8w==", "license": "MPL-2.0", "peerDependencies": { "@capacitor/core": "^8.0.0" @@ -3277,10 +3587,418 @@ "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -3379,9 +4097,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { @@ -3391,7 +4109,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -3496,32 +4214,32 @@ "license": "MIT" }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/react": { - "version": "0.27.19", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", - "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "version": "0.27.20", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz", + "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.8", - "@floating-ui/utils": "^0.2.11", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -3530,12 +4248,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.6" + "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -3543,9 +4261,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@fontsource-variable/roboto-flex": { @@ -3652,9 +4370,9 @@ } }, "node_modules/@gouvfr-lasuite/cunningham-react": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@gouvfr-lasuite/cunningham-react/-/cunningham-react-4.3.1.tgz", - "integrity": "sha512-ECdGJovJ0Sjwo9U+QWZpDtJhfQtnplE2JRdJtTGf+e/Uosk7T5ZjOR9YpP6az7G3urTg9aaYr+j4YAYoZ8bQyg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@gouvfr-lasuite/cunningham-react/-/cunningham-react-4.4.0.tgz", + "integrity": "sha512-nWv1nQvGLIfz1cE08D0cQ1zn05+eX7PWOeenv4yAyDSb4I6pgrNMD+7zxnBlyOj9LPyvk66QClhivaPns66Gnw==", "license": "MIT", "dependencies": { "@fontsource-variable/roboto-flex": "5.2.5", @@ -3771,9 +4489,9 @@ } }, "node_modules/@gouvfr-lasuite/ui-kit": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@gouvfr-lasuite/ui-kit/-/ui-kit-0.26.0.tgz", - "integrity": "sha512-rSP6P7Wh+ioprHIm6VSspm78jBF1WCCJKRCNuTJTt52es85AzT2jrqxSE2FtLGJL67GNrMxT3kF/3DDg2wiH8A==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@gouvfr-lasuite/ui-kit/-/ui-kit-0.27.0.tgz", + "integrity": "sha512-6JKpbT+JGr5oJQHKkT0DiLcy/wMyuHFjmzvS++lpaslRHbPU7O3JYbkt9ilYW/r0wyNc6BRLN8ERpNmgUP3DEQ==", "dependencies": { "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", @@ -4696,9 +5414,9 @@ } }, "node_modules/@mantine/core": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.4.1.tgz", - "integrity": "sha512-lZWEICrum4+vwKxzh/mk4RB1N6BqZ0Cshdl6lhm7OYLiQzmOaxKtOgyaWz+vr65G8mqTXjalZalB+wmMVBYK2Q==", + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.4.2.tgz", + "integrity": "sha512-bEcFalEvbcT8x03GF0eUeWVLF8lGADdfG8jjrPfn+JdK4Sd33ZFdvTZxY/hhkzRi/JsMdhZj8GU0cGkXc0yPzg==", "license": "MIT", "peer": true, "dependencies": { @@ -4709,15 +5427,15 @@ "type-fest": "^5.7.0" }, "peerDependencies": { - "@mantine/hooks": "9.4.1", + "@mantine/hooks": "9.4.2", "react": "^19.2.0", "react-dom": "^19.2.0" } }, "node_modules/@mantine/hooks": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.4.1.tgz", - "integrity": "sha512-eTI8wmzPx3r98zgKIEuvukmoGTHBhmtI6+9E6o2DbTmEU2eM1bCdjE2vFdf0op2AlRO0KEYEcZhNQi4T/SJk0A==", + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.4.2.tgz", + "integrity": "sha512-1SUuhnbvcV7EfwYN2FcdI9nX2TXhQQMefR7uWsigdor+2Twk0yEKMKct8t9gw6PQtkWF9GZxkg8aJRA3KmhaSA==", "license": "MIT", "peer": true, "peerDependencies": { @@ -4841,6 +5559,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4861,6 +5582,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4881,6 +5605,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4901,6 +5628,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4921,6 +5651,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5079,74 +5812,6 @@ "typedoc": "^0.28.14" } }, - "node_modules/@orval/core/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@orval/core/node_modules/@esbuild/darwin-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", @@ -5164,74 +5829,6 @@ "node": ">=18" } }, - "node_modules/@orval/core/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@orval/core/node_modules/@esbuild/linux-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", @@ -5249,278 +5846,6 @@ "node": ">=18" } }, - "node_modules/@orval/core/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@orval/core/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@orval/core/node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -5659,9 +5984,9 @@ } }, "node_modules/@parcel/watcher": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", - "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5669,7 +5994,7 @@ "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">= 10.0.0" @@ -5679,25 +6004,24 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.6", - "@parcel/watcher-darwin-arm64": "2.5.6", - "@parcel/watcher-darwin-x64": "2.5.6", - "@parcel/watcher-freebsd-x64": "2.5.6", - "@parcel/watcher-linux-arm-glibc": "2.5.6", - "@parcel/watcher-linux-arm-musl": "2.5.6", - "@parcel/watcher-linux-arm64-glibc": "2.5.6", - "@parcel/watcher-linux-arm64-musl": "2.5.6", - "@parcel/watcher-linux-x64-glibc": "2.5.6", - "@parcel/watcher-linux-x64-musl": "2.5.6", - "@parcel/watcher-win32-arm64": "2.5.6", - "@parcel/watcher-win32-ia32": "2.5.6", - "@parcel/watcher-win32-x64": "2.5.6" + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" } }, "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", - "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", "cpu": [ "arm64" ], @@ -5715,9 +6039,9 @@ } }, "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", - "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", "cpu": [ "arm64" ], @@ -5735,9 +6059,9 @@ } }, "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", - "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", "cpu": [ "x64" ], @@ -5755,9 +6079,9 @@ } }, "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", - "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", "cpu": [ "x64" ], @@ -5775,12 +6099,15 @@ } }, "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", - "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5795,12 +6122,15 @@ } }, "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", - "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5815,12 +6145,15 @@ } }, "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", - "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5835,12 +6168,15 @@ } }, "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5855,12 +6191,15 @@ } }, "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", - "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5875,12 +6214,15 @@ } }, "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", - "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5895,9 +6237,9 @@ } }, "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", - "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", "cpu": [ "arm64" ], @@ -5914,30 +6256,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", - "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", - "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", "cpu": [ "x64" ], @@ -5986,9 +6308,9 @@ } }, "node_modules/@radix-ui/primitive": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", - "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.6.tgz", + "integrity": "sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==", "license": "MIT" }, "node_modules/@radix-ui/react-compose-refs": { @@ -6022,23 +6344,24 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz", - "integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.20.tgz", + "integrity": "sha512-cngVJcvK0yMvR7wICJpv+1uW3Qw4T7QM5sdbb+oE/lxOdTdvF00oaRpWUjVgmjyXe3J+xh7eZyXZlVF3g2g59g==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", + "@radix-ui/primitive": "1.1.6", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-dismissable-layer": "1.1.16", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.12", + "@radix-ui/react-focus-scope": "1.1.13", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -6058,12 +6381,12 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz", - "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.16.tgz", + "integrity": "sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", + "@radix-ui/primitive": "1.1.6", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", @@ -6100,9 +6423,9 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz", - "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.13.tgz", + "integrity": "sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", @@ -6143,9 +6466,9 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", - "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.14.tgz", + "integrity": "sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==", "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.7", @@ -6167,9 +6490,9 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", - "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.8.tgz", + "integrity": "sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA==", "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" @@ -6246,11 +6569,12 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.4.tgz", + "integrity": "sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA==", "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.6", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, @@ -6492,6 +6816,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6509,6 +6836,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6526,6 +6856,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6543,6 +6876,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6560,6 +6896,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6577,6 +6916,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6670,6 +7012,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6683,6 +7028,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -7543,7 +7891,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7560,7 +7907,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7577,7 +7923,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -7594,7 +7939,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7611,7 +7955,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7628,7 +7971,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7645,7 +7987,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7662,7 +8003,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7679,7 +8019,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7696,7 +8035,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -8188,35 +8526,35 @@ } }, "node_modules/@tiptap/core": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.3.tgz", - "integrity": "sha512-TJj5929M96C1KlH796wS8MywfHDh49RhmakOyzyMMc9pFmRj9UXi1gj0TCXgsZtjEOG7B+m/DRvNOvnuvR9kmg==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.29.1.tgz", + "integrity": "sha512-aXTluPLRB2cFyEw77KkGKtTcfhQ6Snf1KPRpQnpYNGQm8uAmJenl0Cui7PRl2OxuY0Kb2PQ4XhR8p63lyLyKvw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "3.27.3" + "@tiptap/pm": "3.29.1" } }, "node_modules/@tiptap/extension-bold": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.27.3.tgz", - "integrity": "sha512-pchbycFppBqBmvk+OxrQLIPZLNd3rNbcm7zOa+OTjluphpAcsJ6AmHBmiRhJUYWitV6/SA+0nujcaWxBp5hNcQ==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.1.tgz", + "integrity": "sha512-2m7Vw4hf8Up/jC4069wmbqAipxd7mHr5UmBXfTcxM6XiVIKoc3HT4qEvLDgRKexvDGZsTN7p4ZppH73Cv7RMgA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.3" + "@tiptap/core": "3.29.1" } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.27.3.tgz", - "integrity": "sha512-PKSM9g8BXzO0Lxm2gOin7FAFvxO6T0QvlXTXVke/GJn/sR1tEz7w5ybsvweytUPmSliUc601pfIUOHKEhIVC2Q==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.29.1.tgz", + "integrity": "sha512-Dv7xE9Qhv9RtWynnzTVgp7GN8BGqG3mZ5jf56EVqLEUmnE+jbt5jreN+C/oESlMsJ07FXeYWZxKM0sa3Pz9gfA==", "license": "MIT", "optional": true, "dependencies": { @@ -8227,27 +8565,27 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.3", - "@tiptap/pm": "3.27.3" + "@tiptap/core": "3.29.1", + "@tiptap/pm": "3.29.1" } }, "node_modules/@tiptap/extension-code": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.27.3.tgz", - "integrity": "sha512-T7JF10Y3k7fyVujXXLVubdUh7fctgBUoiuxbvjotpltg1U/mPbOcT0UW/uif9j3H8TFvAwNApVw3x8bwwFUakA==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.1.tgz", + "integrity": "sha512-1dCacqr+Il816DWPVlaXzEVJdXMZJHeHs69U2rB137AD85KeqKPMqkPCEw1zb36fY5PdnjKrw1J4TmVIuDjPXQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.3" + "@tiptap/core": "3.29.1" } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.27.3.tgz", - "integrity": "sha512-qL+g1Z6MqZvYG4mHTPwub9II2fWZY+2I5VFfV8fzqy1HchBOyY6Df0mRKDNb0bmjBdFabIXnswz3iqLx3mDTbw==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.29.1.tgz", + "integrity": "sha512-2huV5pNVtYTVpzQZ6nlJSXycv3jNzEMpfuflOxdmHekFuKbMzUMhxBYfln69xMyMQYe03IIotEyOhkNOeSrKcQ==", "license": "MIT", "optional": true, "funding": { @@ -8256,107 +8594,80 @@ }, "peerDependencies": { "@floating-ui/dom": "^1.0.0", - "@tiptap/core": "3.27.3", - "@tiptap/pm": "3.27.3" - } - }, - "node_modules/@tiptap/extension-horizontal-rule": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.3.tgz", - "integrity": "sha512-sNCuo0+Q001B5XTVWY+ULpWXQqtBNRmAqXxAexZl44bx3rDqHG8OzjwT4EM0LIj2+BYVbdqwaJ1vfQbZVEEbig==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "3.27.3", - "@tiptap/pm": "3.27.3" + "@tiptap/core": "3.29.1", + "@tiptap/pm": "3.29.1" } }, "node_modules/@tiptap/extension-italic": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.27.3.tgz", - "integrity": "sha512-7VpVi2vn8kGFAc/HLrt96J/E6+LXOGKn8xLhJS863t150yLIG3EPQLA1h+G5O6eo+/w+9YokvJAAJoeDihj1Jg==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.1.tgz", + "integrity": "sha512-WzBTQ6XUv/7I0EzGgImpBa4vFGlHbOCq22NNo4CGjRnIRFyDwC+8mqSNawJzrFOborKjgKbn8dJVlJ+uuVZYKw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.3" - } - }, - "node_modules/@tiptap/extension-paragraph": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.27.3.tgz", - "integrity": "sha512-G5XPCN7lm0nULYPelJC2eUbKbt1q97j67e/HSWxwMYEXhceec5eNAtVfpDCRyXNO9osyBi2kXqLv/IQtxbch9A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "3.27.3" + "@tiptap/core": "3.29.1" } }, "node_modules/@tiptap/extension-strike": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.27.3.tgz", - "integrity": "sha512-dp6Rs3I4zLVJPvAw2Q9yrNuYKcX5LTONH3/oyULvGsrqq5DKz7pqscfwhwnDSzrgm/3aCz6B9r1BVHlYI/DXlA==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.1.tgz", + "integrity": "sha512-7/8yUQN1/P5JCbhT+TycziNG3XWBMXADjNPmDipflpyi4EEApoFSamnj5+JL48qYj7bC1JmGtw7eXANPKva+9Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.3" + "@tiptap/core": "3.29.1" } }, "node_modules/@tiptap/extension-text": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.27.3.tgz", - "integrity": "sha512-wSatZ/bop0pleeC18nF90zvWWEoRe/ewZncvm8LjcMrXwq41kTEs3j7nZflMYAh7X8fZvu00UIMeEyCNJl+5Yg==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.1.tgz", + "integrity": "sha512-SdwfKTchZf0saMSwmXFEqpYMjgAGXaiUGuwYic/oB5Ri6NCTH2gy8SrCqISBLfUOP7YXGtPGyifZtMtXKAgFAg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.3" + "@tiptap/core": "3.29.1" } }, "node_modules/@tiptap/extension-underline": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.27.3.tgz", - "integrity": "sha512-YH7ka/Rp7ZwlzTuQCcs0OZ4CMSx/P/MzbseQKxKebEIZreWaKMZAaED97Fnu0dA66VlreD4/1C7xHJy84ovamg==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.1.tgz", + "integrity": "sha512-BsZk6GDmmlPFqAtUbEQtiwuLAihLt9A4YvyZEwLm7d2zMbzMLJZK/5RD2JvKGJ0jwrcS3ZIo015bEvKa8h/3KQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.3" + "@tiptap/core": "3.29.1" } }, "node_modules/@tiptap/extensions": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.3.tgz", - "integrity": "sha512-IA2QKUVJgzUPEhhkUfr6P5u5GFVfhiApiEaaHXBz07saL2c4HNoVs2RC18QlZDCtLNKrPR1mUScr72u7uYs+YQ==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.1.tgz", + "integrity": "sha512-GZMW2r56dnTS28o2wlW8vHxUU4hofZryw03eJnch+amap96tjSU81aV0JCHUN/ySd43KKR5fEZMjhELk6VeYpQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "3.27.3", - "@tiptap/pm": "3.27.3" + "@tiptap/core": "3.29.1", + "@tiptap/pm": "3.29.1" } }, "node_modules/@tiptap/pm": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.3.tgz", - "integrity": "sha512-ppiG57RxM3HSHHgcHT0hP6Ib4P56Sd5itxdV4w0hIGHKPGyLLKiAkYp+htIHa9T7IvjMdaqxIlsfyV3ZKsS1sw==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.1.tgz", + "integrity": "sha512-fAzlr2RoV9amvMzioJ0dJHtehsMAO8RZktA9XwDk/Qy+WwgMjlCi9g19d4e6cR/WEwaC5KBsQkAyaWzQYGFxAA==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.4.1", @@ -8366,7 +8677,7 @@ "prosemirror-history": "^1.5.0", "prosemirror-inputrules": "^1.5.1", "prosemirror-keymap": "^1.2.3", - "prosemirror-model": "^1.25.9", + "prosemirror-model": "^1.25.11", "prosemirror-schema-list": "^1.5.1", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.5", @@ -8379,9 +8690,9 @@ } }, "node_modules/@tiptap/react": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.27.3.tgz", - "integrity": "sha512-lanXxMScw/LevbRFuFw5MoQJ1grmHUDszwph2VIk4l2U5e/EDIw3rr4CeDME6Z4isVReD6zccMFVShkHqsv+jg==", + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.29.1.tgz", + "integrity": "sha512-wiaLIUOBWB8j2IzYTWoBelDBXX3kKfU1Sh+UdjYIvk7as8DZniLW7Uw0z1hBFreU8PU/RtA10kWsnICGuaKOyQ==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -8393,12 +8704,12 @@ "url": "https://github.com/sponsors/ueberdosis" }, "optionalDependencies": { - "@tiptap/extension-bubble-menu": "^3.27.3", - "@tiptap/extension-floating-menu": "^3.27.3" + "@tiptap/extension-bubble-menu": "^3.29.1", + "@tiptap/extension-floating-menu": "^3.29.1" }, "peerDependencies": { - "@tiptap/core": "3.27.3", - "@tiptap/pm": "3.27.3", + "@tiptap/core": "3.29.1", + "@tiptap/pm": "3.29.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -8534,13 +8845,6 @@ "node": ">=0.3.1" } }, - "node_modules/@trapezedev/project/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/@trapezedev/project/node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -8804,7 +9108,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", "integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", @@ -9172,14 +9477,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "engines": { @@ -9194,14 +9499,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9212,9 +9517,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", "engines": { @@ -9408,9 +9713,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -9422,16 +9727,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -9450,16 +9755,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9474,13 +9779,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -10061,9 +10366,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", - "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", "dev": true, "license": "MIT", "dependencies": { @@ -10364,9 +10669,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.43", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", - "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz", + "integrity": "sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -10472,9 +10777,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -10492,9 +10797,9 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, @@ -11114,6 +11419,19 @@ "dot-prop": "^5.1.0" } }, + "node_modules/compare-func/node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/compare-versions": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", @@ -12123,12 +12441,6 @@ } } }, - "node_modules/debug/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -12275,16 +12587,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -12489,19 +12791,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/downshift": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.2.0.tgz", @@ -12544,9 +12833,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.392", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", - "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", + "version": "1.5.395", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", "dev": true, "license": "ISC" }, @@ -12613,16 +12902,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -12778,9 +13057,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", - "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, @@ -13330,9 +13609,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -13511,9 +13790,9 @@ "license": "MIT" }, "node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", "dependencies": { @@ -13827,6 +14106,16 @@ "node": ">=10" } }, + "node_modules/get-pkg-repo/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -15263,22 +15552,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/ink/node_modules/type-fest": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", - "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/ink/node_modules/wrap-ansi": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", @@ -15533,22 +15806,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -15993,19 +16250,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -16014,9 +16258,9 @@ "license": "MIT" }, "node_modules/isbot": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.0.tgz", - "integrity": "sha512-gbZiGCb4B5xaoxg9mS7koAyRdvJnArk10VLSHOgz6rtBG93/pi1xOFaVvXMKZ7JXgyZ8zAbNRK5uIBdIUTFSqw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.1.tgz", + "integrity": "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==", "license": "Unlicense", "engines": { "node": ">=18" @@ -16029,16 +16273,6 @@ "dev": true, "license": "ISC" }, - "node_modules/isomorphic.js": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", - "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", - "license": "MIT", - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -16431,31 +16665,10 @@ "node": ">= 0.8.0" } }, - "node_modules/lib0": { - "version": "0.2.117", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", - "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", - "license": "MIT", - "dependencies": { - "isomorphic.js": "^0.2.4" - }, - "bin": { - "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", - "0gentesthtml": "bin/gentesthtml.js", - "0serve": "bin/0serve.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -16469,23 +16682,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -16504,9 +16717,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -16525,9 +16738,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -16546,9 +16759,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -16567,9 +16780,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -16588,13 +16801,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -16609,13 +16825,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -16630,13 +16849,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -16651,13 +16873,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -16672,9 +16897,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -16693,9 +16918,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -16856,6 +17081,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, "license": "MIT" }, "node_modules/lodash.omitby": { @@ -17940,10 +18166,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/mute-stream": { @@ -18408,9 +18633,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -18447,24 +18672,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/openapi-types": { "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", @@ -18825,13 +19032,14 @@ } }, "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.6", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" }, @@ -19158,9 +19366,9 @@ } }, "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", "dev": true, "funding": [ { @@ -19178,7 +19386,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -19501,9 +19709,9 @@ } }, "node_modules/prosemirror-model": { - "version": "1.25.10", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.10.tgz", - "integrity": "sha512-9n6rH4DbJU1eH4SxLt6Y0HhJIo6cZsb7DJ/30uob1hOKPeO6TAaMWI2tc7kwR92BjfPOU2fFHWbZLovLi3XQfA==", + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" @@ -19554,9 +19762,9 @@ } }, "node_modules/prosemirror-view": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.0.tgz", - "integrity": "sha512-N54DF3OXNWDuP81G1kbfCys8ZzIjuL1VnvJ2mk5STSu/fNxWIcX/EutQLA3s9KR/2wVhgDi4hzBB/1fINVxk0A==", + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.1.tgz", + "integrity": "sha512-rRqzZnRgkyh69XoOMrfFJHwauHscLBmHbq772kwbic1ymQAM8gXjzEbJse5j1ep2UO2HRIAQL0bY3kZ/RoqjVw==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.25.8", @@ -20234,9 +20442,9 @@ } }, "node_modules/react-toastify": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", - "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.1.0.tgz", + "integrity": "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==", "license": "MIT", "dependencies": { "clsx": "^2.1.1" @@ -21615,18 +21823,18 @@ } }, "node_modules/seroval": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.5.tgz", - "integrity": "sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==", + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/seroval-plugins": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.5.tgz", - "integrity": "sha512-+BDhqYM6CEn3x09v44dpa9p6974FuUB2dxk+Ctn04k0cO1Zt6QODTXfmEZK0eBaTe/fJBvP4NMGuNJ+R8T+QMg==", + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", "license": "MIT", "engines": { "node": ">=10" @@ -22075,17 +22283,6 @@ "source-map": "^0.6.0" } }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -22562,7 +22759,6 @@ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "license": "MIT", - "peer": true, "engines": { "node": ">=20" }, @@ -22850,22 +23046,22 @@ } }, "node_modules/tldts": { - "version": "7.4.8", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", - "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.8" + "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.8", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", - "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", "dev": true, "license": "MIT" }, @@ -23101,7 +23297,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "license": "(MIT OR CC0-1.0)", - "peer": true, "dependencies": { "tagged-tag": "^1.0.0" }, @@ -24393,9 +24588,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { @@ -24555,50 +24750,6 @@ "node": ">=0.4" } }, - "node_modules/y-prosemirror": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", - "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==", - "license": "MIT", - "dependencies": { - "lib0": "^0.2.109" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - }, - "peerDependencies": { - "prosemirror-model": "^1.7.1", - "prosemirror-state": "^1.2.3", - "prosemirror-view": "^1.9.10", - "y-protocols": "^1.0.1", - "yjs": "^13.5.38" - } - }, - "node_modules/y-protocols": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", - "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", - "license": "MIT", - "dependencies": { - "lib0": "^0.2.85" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - }, - "peerDependencies": { - "yjs": "^13.0.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -24652,13 +24803,13 @@ } }, "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/yargs/node_modules/is-fullwidth-code-point": { @@ -24686,16 +24837,6 @@ "node": ">=8" } }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", @@ -24707,23 +24848,6 @@ "fd-slicer": "~1.1.0" } }, - "node_modules/yjs": { - "version": "13.6.31", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", - "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", - "license": "MIT", - "dependencies": { - "lib0": "^0.2.99" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index f1239dc2..30aec0ca 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -32,20 +32,21 @@ "check:deps": "node scripts/check-node-modules.mjs" }, "dependencies": { - "@blocknote/core": "0.51.4", - "@blocknote/mantine": "0.51.4", - "@blocknote/react": "0.51.4", - "@capacitor/app": "8.1.0", - "@capacitor/browser": "8.0.3", - "@capacitor/core": "8.4.0", - "@capacitor/device": "8.0.2", + "@blocknote/core": "0.52.1", + "@blocknote/mantine": "0.52.1", + "@blocknote/react": "0.52.1", + "@capacitor/app": "8.1.1", + "@capacitor/browser": "8.0.4", + "@capacitor/core": "8.4.2", + "@capacitor/device": "8.0.3", "@capacitor/filesystem": "8.1.2", - "@capacitor/push-notifications": "8.1.1", + "@capacitor/keyboard": "8.0.5", + "@capacitor/push-notifications": "8.1.2", "@capacitor/share": "8.0.1", - "@capgo/capacitor-updater": "8.50.1", - "@gouvfr-lasuite/cunningham-react": "4.3.1", + "@capgo/capacitor-updater": "8.51.2", + "@gouvfr-lasuite/cunningham-react": "4.4.0", "@gouvfr-lasuite/drive-sdk": "0.0.2", - "@gouvfr-lasuite/ui-kit": "0.26.0", + "@gouvfr-lasuite/ui-kit": "0.27.0", "@hookform/resolvers": "5.2.2", "@sentry/react": "10.53.1", "@tanstack/react-query": "5.90.20", @@ -53,8 +54,8 @@ "@tanstack/react-router": "1.170.8", "@tanstack/react-router-devtools": "1.167.0", "@tanstack/react-table": "8.21.3", - "@tiptap/core": "^3.27.3", - "@tiptap/pm": "^3.27.3", + "@tiptap/core": "3.29.1", + "@tiptap/pm": "3.29.1", "@viselect/react": "3.9.0", "clsx": "2.1.1", "date-fns": "4.1.0", @@ -71,7 +72,7 @@ "react-icons": "5.7.0", "react-markdown": "10.1.0", "react-resizable-panels": "4.2.2", - "react-toastify": "11.0.5", + "react-toastify": "11.1.0", "sass": "1.97.3", "ts-ics": "2.4.0", "zod": "4.3.6", @@ -115,7 +116,7 @@ "overrides": { "zod": "4.3.6", "@types/node": "24.13.2", - "@gouvfr-lasuite/cunningham-react": "4.3.1", + "@gouvfr-lasuite/cunningham-react": "4.4.0", "react-aria": "3.50.0", "react-stately": "3.48.0", "react-aria-components": "1.19.0", diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 7f5fa718..296978a6 100644 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -204,6 +204,7 @@ "API Key": "API Key", "API key in header — for receivers that can only check a static header value": "API key in header — for receivers that can only check a static header value", "Apple (iPhone / iPad)": "Apple (iPhone / iPad)", + "Apply": "Apply", "Archive": "Archive", "Archives": "Archives", "Are you sure you want to delete this auto-reply? This action is irreversible!": "Are you sure you want to delete this auto-reply? This action is irreversible!", @@ -242,7 +243,7 @@ "Automatically create mailboxes according to OIDC emails": "Automatically create mailboxes according to OIDC emails", "Awaiting response": "Awaiting response", "Back": "Back", - "Back to imports": "Back to imports", + "Back to imports list": "Back to imports list", "Back to your inbox": "Back to your inbox", "bcc": "bcc", "BCC: ": "BCC: ", @@ -271,7 +272,6 @@ "Close filters": "Close filters", "Close the feedback widget": "Close the feedback widget", "Close the menu": "Close the menu", - "Close this thread": "Close this thread", "Collapse": "Collapse", "Collapse {{name}}": "Collapse {{name}}", "Collapse all": "Collapse all", @@ -439,6 +439,7 @@ "Failed to update import.": "Failed to update import.", "Failed to update integration.": "Failed to update integration.", "Failed to update signature.": "Failed to update signature.", + "Fetching mail…": "Fetching mail…", "Filter by: {{filters}}": "Filter by: {{filters}}", "Filter threads": "Filter threads", "First name": "First name", @@ -448,6 +449,8 @@ "For your security and privacy, external images are not displayed.": "For your security and privacy, external images are not displayed.", "Forced": "Forced", "Forced signature": "Forced signature", + "Format": "Format", + "Formatting options": "Formatting options", "Forward": "Forward", "Forward every incoming message to a URL of your choice.": "Forward every incoming message to a URL of your choice.", "Forwarded message": "Forwarded message", @@ -463,6 +466,7 @@ "Grant editor access to the thread?": "Grant editor access to the thread?", "Help center & Support": "Help center & Support", "Hide": "Hide", + "Hide keyboard": "Hide keyboard", "How the receiver authenticates our requests. The credential is shown once at creation.": "How the receiver authenticates our requests. The credential is shown once at creation.", "How to allow IMAP connections from your account {{name}}?": "How to allow IMAP connections from your account {{name}}?", "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.": "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.", @@ -526,6 +530,7 @@ "Leave this thread?": "Leave this thread?", "less than a minute ago": "less than a minute ago", "Link copied to clipboard": "Link copied to clipboard", + "Link URL": "Link URL", "Loading addresses...": "Loading addresses...", "Loading auto-replies...": "Loading auto-replies...", "Loading auto-reply...": "Loading auto-reply...", @@ -550,6 +555,7 @@ "Maildomains management": "Maildomains management", "Manage {{entity}} accesses": "Manage {{entity}} accesses", "Manage accesses": "Manage accesses", + "Manage labels": "Manage labels", "Mandatory 2FA": "Mandatory 2FA", "Mandatory 2FA disabled for {{mailbox}}.": "Mandatory 2FA disabled for {{mailbox}}.", "Mandatory 2FA enabled for {{mailbox}}.": "Mandatory 2FA enabled for {{mailbox}}.", @@ -694,6 +700,7 @@ "Remove {{displayName}}": "Remove {{displayName}}", "Remove access?": "Remove access?", "Remove from list (keep messages)": "Remove from list (keep messages)", + "Remove link": "Remove link", "Remove report": "Remove report", "Remove spam report": "Remove spam report", "Remove tag": "Remove tag", @@ -709,6 +716,7 @@ "Reset password": "Reset password", "Reset password of {{mailbox}}": "Reset password of {{mailbox}}", "Response saved — the organizer will be notified": "Response saved — the organizer will be notified", + "Restore": "Restore", "Resume integration": "Resume integration", "Resume polling": "Resume polling", "Retry": "Retry", @@ -770,6 +778,7 @@ "Sign in with another address": "Sign in with another address", "Sign out": "Sign out", "Sign out \"{{name}}\"": "Sign out \"{{name}}\"", + "Signature": "Signature", "Signature created!": "Signature created!", "Signature deleted!": "Signature deleted!", "Signature updated!": "Signature updated!", @@ -808,7 +817,6 @@ "Summary": "Summary", "Summary refreshed!": "Summary refreshed!", "Sunday": "Sunday", - "Switch account": "Switch account", "Synchronize mailboxes with an identity provider": "Synchronize mailboxes with an identity provider", "Tags": "Tags", "Target": "Target", @@ -891,7 +899,6 @@ "Tutorials and training": "Tutorials and training", "Type": "Type", "Unable to check task status.": "Unable to check task status.", - "Unable to check the import status.": "Unable to check the import status.", "Unable to copy credentials.": "Unable to copy credentials.", "Unable to copy to clipboard.": "Unable to copy to clipboard.", "Unarchive": "Unarchive", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index b1f5897e..1e0c7fd9 100644 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -278,6 +278,7 @@ "API Key": "Clé API", "API key in header — for receivers that can only check a static header value": "Clé API dans l'en-tête — pour les destinataires qui ne peuvent vérifier qu'une valeur d'en-tête statique", "Apple (iPhone / iPad)": "Apple (iPhone / iPad)", + "Apply": "Appliquer", "Archive": "Archiver", "Archives": "Archives", "Are you sure you want to delete this auto-reply? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette réponse automatique ? Cette action est irréversible !", @@ -317,7 +318,7 @@ "Automatically create mailboxes according to OIDC emails": "Créer les boîtes aux lettres automatiquement selon les emails OIDC", "Awaiting response": "En attente de réponse", "Back": "Précédent", - "Back to imports": "Retour aux imports", + "Back to imports list": "Retour à la liste des imports", "Back to your inbox": "Retour à votre messagerie", "bcc": "cci", "BCC: ": "CCI : ", @@ -347,7 +348,6 @@ "Close filters": "Fermer les filtres", "Close the feedback widget": "Fermer le widget de retours", "Close the menu": "Fermer le menu", - "Close this thread": "Fermer cette conversation", "Collapse": "Réduire", "Collapse {{name}}": "Réduire {{name}}", "Collapse all": "Tout réduire", @@ -519,6 +519,7 @@ "Failed to update import.": "Échec de la mise à jour de l'import.", "Failed to update integration.": "Échec de la mise à jour de l'intégration.", "Failed to update signature.": "Erreur lors de la mise à jour de la signature.", + "Fetching mail…": "Relève du courrier…", "Filter by: {{filters}}": "Filtrer par : {{filters}}", "Filter threads": "Filtrer les conversations", "First name": "Prénom", @@ -528,6 +529,8 @@ "For your security and privacy, external images are not displayed.": "Pour votre sécurité et votre vie privée, les images externes ne sont pas affichées.", "Forced": "Forcée", "Forced signature": "Signature forcée", + "Format": "Mise en forme", + "Formatting options": "Options de mise en forme", "Forward": "Transférer", "Forward every incoming message to a URL of your choice.": "Transférez chaque message entrant vers une URL de votre choix.", "Forwarded message": "Message transféré", @@ -543,6 +546,7 @@ "Grant editor access to the thread?": "Accorder l'accès en édition à la conversation ?", "Help center & Support": "Centre d'aide et Support", "Hide": "Masquer", + "Hide keyboard": "Masquer le clavier", "How the receiver authenticates our requests. The credential is shown once at creation.": "Comment le destinataire authentifie nos requêtes. L'identifiant est affiché une seule fois lors de la création.", "How to allow IMAP connections from your account {{name}}?": "Comment autoriser les connexions IMAP depuis votre compte {{name}} ?", "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.": "Je confirme que cette adresse correspond à l'identité d'une personne physique travaillant avec moi, et m'engage à la désactiver quand son poste prendra fin.", @@ -557,7 +561,6 @@ "Import": "Importer", "Import actions": "Actions sur l'import", "Import cancelled and messages deleted.": "Import annulé et messages supprimés.", - "Import complete": "Importation terminée", "Import in progress": "Import en cours", "Import mail into this mailbox from an archive (PST, MBOX, EML) or an IMAP account, and track or cancel running imports.": "Importez des messages dans cette boîte depuis une archive (PST, MBOX, EML) ou un compte IMAP, et suivez ou annulez les imports en cours.", "Import messages": "Importer des messages", @@ -610,6 +613,7 @@ "Leave this thread?": "Quitter cette conversation ?", "less than a minute ago": "il y a moins d'une minute", "Link copied to clipboard": "Lien copié dans le presse-papiers", + "Link URL": "URL du lien", "Loading addresses...": "Chargement des adresses...", "Loading auto-replies...": "Chargement des réponses automatiques...", "Loading auto-reply...": "Chargement de la réponse automatique...", @@ -634,6 +638,7 @@ "Maildomains management": "Gestion des domaines", "Manage {{entity}} accesses": "Gérer les accès à {{entity}}", "Manage accesses": "Gérer les accès", + "Manage labels": "Gérer les libellés", "Mandatory 2FA": "2FA obligatoire", "Mandatory 2FA disabled for {{mailbox}}.": "2FA obligatoire désactivée pour {{mailbox}}.", "Mandatory 2FA enabled for {{mailbox}}.": "2FA obligatoire activée pour {{mailbox}}.", @@ -779,6 +784,7 @@ "Remove {{displayName}}": "Supprimer {{displayName}}", "Remove access?": "Retirer l'accès ?", "Remove from list (keep messages)": "Retirer de la liste (conserver les messages)", + "Remove link": "Supprimer le lien", "Remove report": "Annuler le signalement", "Remove spam report": "Annuler le signalement spam", "Remove tag": "Supprimer le tag", @@ -794,6 +800,7 @@ "Reset password": "Réinitialiser le mot de passe", "Reset password of {{mailbox}}": "Réinitialiser le mot de passe de {{mailbox}}", "Response saved — the organizer will be notified": "Réponse enregistrée — l'organisateur sera notifié", + "Restore": "Restaurer", "Resume integration": "Réactiver l'intégration", "Resume polling": "Reprendre la synchronisation", "Retry": "Réessayer", @@ -858,6 +865,7 @@ "Sign in with another address": "Se connecter avec une autre adresse", "Sign out": "Déconnecter", "Sign out \"{{name}}\"": "", + "Signature": "Signature", "Signature created!": "Signature créée !", "Signature deleted!": "Signature supprimée !", "Signature updated!": "Signature mise à jour !", @@ -897,7 +905,6 @@ "Summary": "Résumé", "Summary refreshed!": "Résumé mis à jour !", "Sunday": "Dimanche", - "Switch account": "Changer de compte", "Synchronize mailboxes with an identity provider": "Synchroniser les boîtes aux lettres avec un fournisseur d'identité", "Tags": "Libellés", "Target": "Cible", diff --git a/src/frontend/src/bootstrap.tsx b/src/frontend/src/bootstrap.tsx index c618dd1e..518dfaff 100644 --- a/src/frontend/src/bootstrap.tsx +++ b/src/frontend/src/bootstrap.tsx @@ -10,6 +10,7 @@ import { initI18n } from "@/features/i18n/initI18n"; import { installThemeFavicons } from "@/features/providers/theme-favicons"; import { initSentry } from "@/features/sentry"; import { handle } from '@/features/utils/errors'; +import { hideKeyboardAccessoryBar } from "./features/native/keyboard"; import { checkAndApplyOtaUpdate, notifyOtaAppReady } from "./features/native/ota"; import { listenForNativePushTaps } from "./features/native/push"; @@ -19,6 +20,10 @@ import { listenForNativePushTaps } from "./features/native/push"; if (isNativePlatform()) { document.documentElement.classList.add("native"); + // The composer pins its own formatting toolbar above the keyboard; iOS's + // form accessory bar would stack under it, so hide it once and for all. + void hideKeyboardAccessoryBar(); + // Pinch-zooming the whole shell makes it feel like a website, not an app, so // lock the viewport scale — but only here: on the web the index.html viewport // stays zoomable (accessibility, WCAG 1.4.4). diff --git a/src/frontend/src/features/blocknote/blocknote-view-field/_index.scss b/src/frontend/src/features/blocknote/blocknote-view-field/_index.scss index b4160a08..ee358385 100644 --- a/src/frontend/src/features/blocknote/blocknote-view-field/_index.scss +++ b/src/frontend/src/features/blocknote/blocknote-view-field/_index.scss @@ -139,3 +139,10 @@ } } } + +// Specific styles for native platforms +:root.native { + & .bn-editor { + padding-block: var(--c--globals--spacings--base); + } +} \ No newline at end of file diff --git a/src/frontend/src/features/blocknote/blocknote-view-field/index.tsx b/src/frontend/src/features/blocknote/blocknote-view-field/index.tsx index 1078a3fb..3413568b 100644 --- a/src/frontend/src/features/blocknote/blocknote-view-field/index.tsx +++ b/src/frontend/src/features/blocknote/blocknote-view-field/index.tsx @@ -4,6 +4,7 @@ import { Field, FieldProps } from "@gouvfr-lasuite/cunningham-react"; import clsx from "clsx"; import { PropsWithChildren } from "react"; +import { isNativePlatform } from "@/features/native/platform"; import { CustomSideMenuController } from "../custom-side-menu"; import { CustomSlashMenu } from "../custom-slash-menu"; @@ -22,6 +23,11 @@ export const BlockNoteViewField = ; + +/** + * True when the selection sits on blocks that can hold inline content — + * i.e. an image block can be inserted after them. + */ +export const cursorHasInlineContent = (editor: Editor) => { + const selectedBlocks = editor.getSelection()?.blocks || [ + editor.getTextCursorPosition().block, + ]; + return selectedBlocks.some((block) => block.content !== undefined); +}; + +/** + * Inserts an empty image block after the current one and opens the file + * panel on it. Shared between the desktop toolbar button and the mobile + * format panel. + */ +export const insertImageBlock = (editor: Editor) => { + const currentBlock = editor.getTextCursorPosition().block; + const insertedBlocks = editor.insertBlocks( + [{ type: "image" }], + currentBlock, + "after", + ); + const filePanel = editor.getExtension("filePanel") as { showMenu: (blockId: string) => void } | undefined; + filePanel?.showMenu(insertedBlocks[0].id); +}; + export const ImageUploadButton = () => { const { t } = useTranslation(); - const editor = useBlockNoteEditor(); + const editor = useBlockNoteEditor(); const Components = useComponentsContext()!; const hasInlineContent = useEditorState({ editor, - selector: ({ editor }) => { - const selectedBlocks = editor.getSelection()?.blocks || [ - editor.getTextCursorPosition().block, - ]; - return selectedBlocks.some((block) => block.content !== undefined); - }, + selector: ({ editor }) => cursorHasInlineContent(editor), }); if (!hasInlineContent) return null; - const handleClick = () => { - const currentBlock = editor.getTextCursorPosition().block; - const insertedBlocks = editor.insertBlocks( - [{ type: "image" }], - currentBlock, - "after", - ); - const filePanel = editor.getExtension("filePanel") as { showMenu: (blockId: string) => void } | undefined; - filePanel?.showMenu(insertedBlocks[0].id); - }; - return ( } label={t("Insert image")} mainTooltip={t("Insert image")} - onClick={handleClick} + onClick={() => insertImageBlock(editor)} /> ); }; diff --git a/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.test.ts b/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.test.ts index b2575abf..22d1d1ca 100644 --- a/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.test.ts +++ b/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.test.ts @@ -8,9 +8,9 @@ * editor, no DOM, no React — by driving the extension through its public * surface (the keyboard shortcuts and the ProseMirror plugin it registers). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { Schema, type Slice } from '@tiptap/pm/model'; -import { EditorState, type Plugin, type Transaction } from '@tiptap/pm/state'; +import { EditorState, TextSelection, type Plugin, type Transaction } from '@tiptap/pm/state'; import type { EditorView } from '@tiptap/pm/view'; import type { Editor } from '@tiptap/core'; import { TemplateVariableEditingBehavior } from './editing-behavior'; @@ -30,6 +30,7 @@ const schema = new Schema({ group: 'inline', inline: true, content: 'text*', + attrs: { value: { default: '' }, label: { default: '' } }, toDOM: () => ['span', 0], parseDOM: [{ tag: 'span' }], }, @@ -39,11 +40,20 @@ const schema = new Schema({ type Built = { state: EditorState; tokenPos: number; tokenSize: number }; -/** Builds `

{leading}[{token}]{trailing}

` and locates the token node. */ -const buildDoc = (leading: string, token: string, trailing: string): Built => { +/** + * Builds `

{leading}[{token}]{trailing}

` and locates the token node. + * `label` defaults to the token text; passing a different one simulates a + * token whose content diverged from its label (IME leakage). + */ +const buildDoc = ( + leading: string, + token: string, + trailing: string, + label = token, +): Built => { const inline = []; if (leading) inline.push(schema.text(leading)); - inline.push(schema.node('template-variable', null, [schema.text(token)])); + inline.push(schema.node('template-variable', { label, value: 'v' }, [schema.text(token)])); if (trailing) inline.push(schema.text(trailing)); const doc = schema.node('doc', null, [schema.node('paragraph', null, inline)]); const state = EditorState.create({ doc, schema }); @@ -63,11 +73,15 @@ const buildDoc = (leading: string, token: string, trailing: string): Built => { type ShortcutMap = Record boolean>; type ExtConfig = { addKeyboardShortcuts: () => ShortcutMap; - addProseMirrorPlugins: () => Plugin[]; + // TipTap invokes this with the extension storage as `this`; the plugin + // captures `this.editor` for the beforeinput deletion path. + addProseMirrorPlugins: (this: { editor: Editor }) => Plugin[]; }; const config = TemplateVariableEditingBehavior.config as unknown as ExtConfig; const shortcuts = config.addKeyboardShortcuts(); -const [readonlyPlugin] = config.addProseMirrorPlugins(); +const buildPlugin = (editor: Editor) => + config.addProseMirrorPlugins.call({ editor })[0]; +const readonlyPlugin = buildPlugin({} as Editor); // ProseMirror types `EditorProps` methods with a `this: Plugin` context, which // trips TS when calling them as plain functions. Re-type just the handlers we @@ -81,13 +95,18 @@ const handlers = readonlyPlugin.props as unknown as ReadonlyHandlers; /** * Fakes the slice of the TipTap editor that `deleteTemplateVariable` reads: - * a collapsed (or not) selection at `cursorPos` plus a capturing dispatch. + * a collapsed (or ranged, up to `toPos`) selection plus a capturing dispatch. */ -const makeEditor = (state: EditorState, cursorPos: number, empty = true) => { +const makeEditor = ( + state: EditorState, + cursorPos: number, + toPos = cursorPos, +) => { let dispatched: Transaction | null = null; const $from = state.doc.resolve(cursorPos); + const $to = state.doc.resolve(toPos); const editor = { - state: { selection: { empty, $from }, tr: state.tr }, + state: { selection: { empty: toPos === cursorPos, $from, $to }, tr: state.tr }, view: { dispatch: (tr: Transaction) => { dispatched = tr; } }, } as unknown as Editor; return { @@ -100,9 +119,9 @@ const press = ( key: 'Backspace' | 'Delete', b: Built, pos: number, - empty = true, + toPos = pos, ) => { - const { editor, resultText } = makeEditor(b.state, pos, empty); + const { editor, resultText } = makeEditor(b.state, pos, toPos); const handled = shortcuts[key]({ editor }); return { handled, text: resultText() }; }; @@ -164,10 +183,33 @@ describe('TemplateVariableEditingBehavior — atomic deletion', () => { expect(text).toBeNull(); }); - it('leaves a non-collapsed selection to the default handler', () => { + it('a range fully inside the token deletes the whole token', () => { const b = buildDoc('Hello ', '{name}', ' world'); - // Cursor inside the token, but the selection is a range, not a caret. - const { handled, text } = press('Backspace', b, b.tokenPos + 2, false); + const { handled, text } = press('Backspace', b, b.tokenPos + 2, b.tokenPos + 4); + expect(handled).toBe(true); + expect(text).toBe('Hello world'); + }); + + it('a range entering the token from the left swallows the whole token', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + // From after "He" (pos 3) to inside the token. + const { handled, text } = press('Backspace', b, 3, b.tokenPos + 2); + expect(handled).toBe(true); + expect(text).toBe('He world'); + }); + + it('a range leaving the token to the right swallows the whole token', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + // From inside the token to 2 chars into " world": the token and the two + // selected trailing chars (" w") go together. + const { handled, text } = press('Backspace', b, b.tokenPos + 2, b.tokenPos + b.tokenSize + 2); + expect(handled).toBe(true); + expect(text).toBe('Hello orld'); + }); + + it('leaves a plain-text range to the default handler', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, text } = press('Backspace', b, 2, 4); expect(handled).toBe(false); expect(text).toBeNull(); }); @@ -179,11 +221,29 @@ describe('TemplateVariableEditingBehavior — read-only token content', () => { const outsidePos = 2; // within the leading "Hello " text const viewWithDoc = { state: b.state } as unknown as EditorView; - const viewWithCursor = (pos: number) => - ({ state: { selection: { $from: b.state.doc.resolve(pos) } } }) as unknown as EditorView; + const viewWithCursor = (pos: number, toPos = pos) => + ({ + state: { + selection: { + $from: b.state.doc.resolve(pos), + $to: b.state.doc.resolve(toPos), + }, + }, + }) as unknown as EditorView; - it('blocks text input inside the token', () => { - expect(handlers.handleTextInput(viewWithDoc, insidePos, insidePos, 'x')).toBe(true); + it('lets a collapsed insert bound inside the token through (hoisted later)', () => { + // The caret can never rest inside a token, so a collapsed insert located + // inside one is Chromium binding boundary-typed text into the token DOM. + // It goes through and the appendTransaction hoists it back out. + expect(handlers.handleTextInput(viewWithDoc, insidePos, insidePos, 'x')).toBe(false); + }); + + it('blocks text input over a range that partially covers the token', () => { + expect(handlers.handleTextInput(viewWithDoc, outsidePos, insidePos, 'x')).toBe(true); + }); + + it('blocks text input over a range inside the token', () => { + expect(handlers.handleTextInput(viewWithDoc, insidePos, insidePos + 2, 'x')).toBe(true); }); it('allows text input outside the token', () => { @@ -196,6 +256,12 @@ describe('TemplateVariableEditingBehavior — read-only token content', () => { expect(handlers.handlePaste(viewWithCursor(insidePos), noopClipboard, noopSlice)).toBe(true); }); + it('blocks paste over a range that partially covers the token', () => { + const noopClipboard = {} as unknown as ClipboardEvent; + const noopSlice = {} as unknown as Slice; + expect(handlers.handlePaste(viewWithCursor(outsidePos, insidePos), noopClipboard, noopSlice)).toBe(true); + }); + it('allows paste outside the token', () => { const noopClipboard = {} as unknown as ClipboardEvent; const noopSlice = {} as unknown as Slice; @@ -218,6 +284,214 @@ describe('TemplateVariableEditingBehavior — read-only token content', () => { }); }); +describe('TemplateVariableEditingBehavior — virtual keyboard deletion (beforeinput)', () => { + // Virtual keyboards (GBoard, iOS) delete through beforeinput events, not + // Backspace/Delete keydowns, so the plugin must route them to the same + // atomic deletion as the keyboard shortcuts. + const fireBeforeinput = ( + b: Built, + pos: number, + inputType: string, + cancelable = true, + ) => { + const { editor, resultText } = makeEditor(b.state, pos); + const plugin = buildPlugin(editor); + const { beforeinput } = plugin.props.handleDOMEvents as unknown as { + beforeinput: (view: EditorView, event: InputEvent) => boolean; + }; + let prevented = false; + const event = { + inputType, + cancelable, + preventDefault: () => { prevented = true; }, + } as unknown as InputEvent; + const handled = beforeinput({} as EditorView, event); + return { handled, prevented, text: resultText() }; + }; + + it('deleteContentBackward inside the token removes it whole', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, prevented, text } = fireBeforeinput(b, b.tokenPos + 2, 'deleteContentBackward'); + expect(handled).toBe(true); + expect(prevented).toBe(true); + expect(text).toBe('Hello world'); + }); + + it('deleteContentForward right before the token removes it whole', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, prevented, text } = fireBeforeinput(b, b.tokenPos, 'deleteContentForward'); + expect(handled).toBe(true); + expect(prevented).toBe(true); + expect(text).toBe('Hello world'); + }); + + it('leaves plain-text deletion to the default path', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, prevented, text } = fireBeforeinput(b, 3, 'deleteContentBackward'); + expect(handled).toBe(false); + expect(prevented).toBe(false); + expect(text).toBeNull(); + }); + + it('leaves non-cancelable events to the default path (no double deletion)', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, prevented, text } = fireBeforeinput(b, b.tokenPos + 2, 'deleteContentBackward', false); + expect(handled).toBe(false); + expect(prevented).toBe(false); + expect(text).toBeNull(); + }); + + it('ignores non-deletion input types', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, prevented, text } = fireBeforeinput(b, b.tokenPos + 2, 'insertText'); + expect(handled).toBe(false); + expect(prevented).toBe(false); + expect(text).toBeNull(); + }); +}); + +describe('TemplateVariableEditingBehavior — token integrity (appendTransaction)', () => { + type AppendTx = ( + trs: readonly Transaction[], + oldState: EditorState, + newState: EditorState, + ) => Transaction | null | undefined; + const appendTx = buildPlugin({} as Editor).spec.appendTransaction as unknown as AppendTx; + const identityMapping = { map: (pos: number) => pos }; + const docChanged = [{ docChanged: true, mapping: identityMapping }] as unknown as Transaction[]; + const selectionOnly = [{ docChanged: false, mapping: identityMapping }] as unknown as Transaction[]; + + const withSelection = (state: EditorState, from: number, to = from) => + state.apply( + state.tr.setSelection(TextSelection.create(state.doc, from, to)), + ); + + const tokenOf = (state: EditorState): { text: string; pos: number } | null => { + let found: { text: string; pos: number } | null = null; + state.doc.descendants((node, pos) => { + if (node.type.name === 'template-variable') found = { text: node.textContent, pos }; + }); + return found; + }; + + it('restores the label and hoists stray characters after the token', () => { + // The IME leaked a "!" into the token: "{name}" became "{name}!". + const b = buildDoc('Hello ', '{name}!', ' world', '{name}'); + const tr = appendTx(docChanged, b.state, b.state); + expect(tr).toBeTruthy(); + const result = b.state.apply(tr!); + expect(tokenOf(result)?.text).toBe('{name}'); + expect(result.doc.textContent).toBe('Hello {name}! world'); + // Caret lands after the hoisted "!" so typing continues outside the token. + expect(result.selection.from).toBe(b.tokenPos + '{name}'.length + 2 + 1); + }); + + it('reverts any non-insertion divergence to the label', () => { + // Characters were somehow replaced inside the token: no clean hoist. + const b = buildDoc('Hello ', '{nXme}', ' world', '{name}'); + const tr = appendTx(docChanged, b.state, b.state); + expect(tr).toBeTruthy(); + const result = b.state.apply(tr!); + expect(tokenOf(result)?.text).toBe('{name}'); + expect(result.doc.textContent).toBe('Hello {name} world'); + }); + + it('leaves a doc whose tokens match their label untouched', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + expect(appendTx(docChanged, b.state, b.state)).toBeFalsy(); + }); + + it('pushes a caret entering from the left out to the right side', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const oldState = withSelection(b.state, b.tokenPos); + const newState = withSelection(b.state, b.tokenPos + 2); + const tr = appendTx(selectionOnly, oldState, newState); + expect(tr).toBeTruthy(); + expect(tr!.selection.from).toBe(b.tokenPos + b.tokenSize); + }); + + it('pushes a caret entering from the right out to the left side', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const oldState = withSelection(b.state, b.tokenPos + b.tokenSize); + const newState = withSelection(b.state, b.tokenPos + 2); + const tr = appendTx(selectionOnly, oldState, newState); + expect(tr).toBeTruthy(); + expect(tr!.selection.from).toBe(b.tokenPos); + }); + + it('frees the caret even when the token has no surrounding text', () => { + const b = buildDoc('', '{name}', ''); + const oldState = withSelection(b.state, b.tokenPos); + const newState = withSelection(b.state, b.tokenPos + 1); + const tr = appendTx(selectionOnly, oldState, newState); + expect(tr).toBeTruthy(); + expect(tr!.selection.from).toBe(b.tokenPos + b.tokenSize); + }); + + it('rests the caret after the token when a deletion rebound it inside', () => { + // Backspacing the char right after the token: the browser re-binds the + // caret inside the token DOM. The caret must come back to rest after the + // token — not cross to the other side — so the next backspace swallows + // the token itself. + const b = buildDoc('Hello ', '{name}', ' world'); + const oldState = withSelection(b.state, b.tokenPos + b.tokenSize + 1); + const newState = withSelection(b.state, b.tokenPos + b.tokenSize - 1); + const tr = appendTx(docChanged, oldState, newState); + expect(tr).toBeTruthy(); + expect(tr!.selection.from).toBe(b.tokenPos + b.tokenSize); + }); + + it('rests the caret before the token when a forward deletion rebound it inside', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const oldState = withSelection(b.state, b.tokenPos - 1); + const newState = withSelection(b.state, b.tokenPos + 1); + const tr = appendTx(docChanged, oldState, newState); + expect(tr).toBeTruthy(); + expect(tr!.selection.from).toBe(b.tokenPos); + }); + + it('leaves range selections inside the token alone (styling sweeps)', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const oldState = withSelection(b.state, b.tokenPos); + const newState = withSelection(b.state, b.tokenPos + 2, b.tokenPos + 4); + expect(appendTx(selectionOnly, oldState, newState)).toBeFalsy(); + }); + + it('leaves a caret outside any token alone', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const oldState = withSelection(b.state, 2); + const newState = withSelection(b.state, 3); + expect(appendTx(selectionOnly, oldState, newState)).toBeFalsy(); + }); + + it('repairs IME-composed damage once the composition ends', () => { + // Edits done entirely mid-composition never reach appendTransaction; the + // compositionend handler runs the same normalization, one tick later. + vi.useFakeTimers(); + try { + const b = buildDoc('Hello ', '{name}!', ' world', '{name}'); + let dispatched: Transaction | null = null; + const view = { + isDestroyed: false, + state: b.state, + dispatch: (tr: Transaction) => { dispatched = tr; }, + }; + const { compositionend } = buildPlugin({} as Editor).props + .handleDOMEvents as unknown as { + compositionend: (view: unknown, event: CompositionEvent) => boolean; + }; + compositionend(view, {} as CompositionEvent); + expect(dispatched).toBeNull(); + vi.runAllTimers(); + expect(dispatched).toBeTruthy(); + const result = b.state.apply(dispatched!); + expect(result.doc.textContent).toBe('Hello {name}! world'); + } finally { + vi.useRealTimers(); + } + }); +}); + describe('TemplateVariableEditingBehavior — wiring', () => { it('binds both Backspace and Delete', () => { expect(typeof shortcuts.Backspace).toBe('function'); @@ -225,9 +499,11 @@ describe('TemplateVariableEditingBehavior — wiring', () => { }); it('registers a single ProseMirror plugin exposing the read-only handlers', () => { - expect(config.addProseMirrorPlugins()).toHaveLength(1); + expect(config.addProseMirrorPlugins.call({ editor: {} as Editor })).toHaveLength(1); expect(typeof readonlyPlugin.props.handleTextInput).toBe('function'); expect(typeof readonlyPlugin.props.handlePaste).toBe('function'); expect(typeof readonlyPlugin.props.handleKeyDown).toBe('function'); + const domHandlers = readonlyPlugin.props.handleDOMEvents as unknown as Record; + expect(typeof domHandlers.beforeinput).toBe('function'); }); }); diff --git a/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.ts b/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.ts index 84a884bd..1853b9a9 100644 --- a/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.ts +++ b/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.ts @@ -1,16 +1,139 @@ import { Editor, Extension } from "@tiptap/core"; -import { ResolvedPos } from "@tiptap/pm/model"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Node as PMNode, ResolvedPos } from "@tiptap/pm/model"; +import { + EditorState, + Plugin, + PluginKey, + TextSelection, + Transaction, +} from "@tiptap/pm/state"; import { TEMPLATE_VARIABLE_TYPE } from "."; -/** Returns true when the resolved position sits inside a template-variable node. */ -const isInsideTemplateVariable = ($pos: ResolvedPos): boolean => { +/** + * Returns the whole-token range when the resolved position sits inside a + * template-variable node, null otherwise. + */ +const templateVariableRange = ( + $pos: ResolvedPos, +): { from: number; to: number } | null => { for (let depth = $pos.depth; depth > 0; depth--) { if ($pos.node(depth).type.name === TEMPLATE_VARIABLE_TYPE) { - return true; + return { from: $pos.before(depth), to: $pos.after(depth) }; } } - return false; + return null; +}; + +/** Returns true when the resolved position sits inside a template-variable node. */ +const isInsideTemplateVariable = ($pos: ResolvedPos): boolean => + templateVariableRange($pos) !== null; + +/** + * Builds the transaction (or null) restoring the two token invariants: + * + * 1. A token's text is its label, always. Chromium binds text typed at the + * token boundary (or composed by a mobile IME, e.g. smart punctuation) + * into the token's own DOM text node; the DOM observer then re-parses it + * as token content. The label is restored and stray characters are + * hoisted right after the token, caret behind them. Checked only when + * `checkContent` says the doc may have changed. + * 2. The caret never rests inside a token: its content is read-only, and + * with no adjacent text the browser offers no way back out. A collapsed + * selection landing inside is pushed out. In `traverse` mode (pure caret + * moves) it crosses to the side opposite `cameFrom`, so arrow keys jump + * over the token. In `rest` mode (the doc changed: deleting next to the + * token makes the browser re-bind the caret inside it) it returns to the + * boundary nearest `cameFrom`, so a backspace right after the token + * leaves the caret there and the next backspace swallows the token. + * Range selections stay untouched: sweeping across the chip is how marks + * (bold, color…) get applied to it. + */ +const tokenIntegrityTransaction = ( + state: EditorState, + cameFrom: number, + checkContent: boolean, + mode: "traverse" | "rest", +): Transaction | null => { + if (checkContent) { + const broken: { pos: number; node: PMNode }[] = []; + state.doc.descendants((node, pos) => { + if ( + node.type.name === TEMPLATE_VARIABLE_TYPE && + node.attrs.label && + node.textContent !== node.attrs.label + ) { + broken.push({ pos, node }); + } + return true; + }); + if (broken.length > 0) { + const tr = state.tr; + // Descending doc order keeps earlier positions stable. + for (const { pos, node } of broken.reverse()) { + const label: string = node.attrs.label; + const text = node.textContent; + const marks = node.firstChild?.marks ?? []; + let prefix = 0; + while ( + prefix < label.length && + prefix < text.length && + label[prefix] === text[prefix] + ) { + prefix += 1; + } + let suffix = 0; + while ( + suffix < label.length - prefix && + suffix < text.length - prefix && + label[label.length - 1 - suffix] === text[text.length - 1 - suffix] + ) { + suffix += 1; + } + // Hoist only clean insertions; any other divergence is simply + // reverted to the label. + const isPureInsertion = + text.slice(0, prefix) + text.slice(text.length - suffix) === label; + const hoisted = isPureInsertion + ? text.slice(prefix, text.length - suffix) + : ""; + if (hoisted) { + tr.insert(pos + node.nodeSize, state.schema.text(hoisted, marks)); + } + tr.replaceWith( + pos + 1, + pos + 1 + node.content.size, + state.schema.text(label, marks), + ); + // Token nodeSize is now label.length + 2 (open + close). + const caret = pos + label.length + 2 + hoisted.length; + tr.setSelection(TextSelection.create(tr.doc, caret)); + } + return tr; + } + } + + const { selection } = state; + if (selection.empty && selection instanceof TextSelection) { + const range = templateVariableRange(selection.$from); + if (range) { + const nearestSide = + selection.from - range.from <= range.to - selection.from + ? range.from + : range.to; + const target = + cameFrom <= range.from + ? mode === "traverse" + ? range.to + : range.from + : cameFrom >= range.to + ? mode === "traverse" + ? range.from + : range.to + : nearestSide; + return state.tr.setSelection(TextSelection.create(state.doc, target)); + } + } + return null; }; /** @@ -19,7 +142,9 @@ const isInsideTemplateVariable = ($pos: ResolvedPos): boolean => { * Because the token stores its `{value}` label as styled text, the default * Backspace/Delete would otherwise erase it one character at a time. This * restores atomic-token behavior: a single keystroke removes the entire - * variable when the (collapsed) cursor sits inside it or right next to it. + * variable when the (collapsed) cursor sits inside it or right next to it, + * and a range deletion whose boundary cuts a token swallows that token whole + * — a partially deleted variable would be a corrupt one. * * @param editor - The TipTap editor instance. * @param backward - True for Backspace (look behind), false for Delete (ahead). @@ -29,16 +154,29 @@ const deleteTemplateVariable = (editor: Editor, backward: boolean): boolean => { const { state } = editor; const { selection } = state; if (!selection.empty) { - return false; + // Range deletion: expand each boundary that sits inside a token to the + // token's own bounds, so no token survives half-deleted. Ranges touching + // no token keep the default handling. + const fromToken = templateVariableRange(selection.$from); + const toToken = templateVariableRange(selection.$to); + if (!fromToken && !toToken) { + return false; + } + editor.view.dispatch( + state.tr.delete( + fromToken ? fromToken.from : selection.$from.pos, + toToken ? toToken.to : selection.$to.pos, + ), + ); + return true; } const { $from } = selection; // Cursor inside the token's text: remove the whole node. - for (let depth = $from.depth; depth > 0; depth--) { - if ($from.node(depth).type.name === TEMPLATE_VARIABLE_TYPE) { - editor.view.dispatch(state.tr.delete($from.before(depth), $from.after(depth))); - return true; - } + const insideToken = templateVariableRange($from); + if (insideToken) { + editor.view.dispatch(state.tr.delete(insideToken.from, insideToken.to)); + return true; } // Cursor immediately before/after the token: remove it as a whole. @@ -76,20 +214,106 @@ export const TemplateVariableEditingBehavior = Extension.create({ }, addProseMirrorPlugins() { + const editor = this.editor; return [ new Plugin({ key: new PluginKey("templateVariableReadonlyContent"), props: { - // Block any text inserted while the cursor sits within a token. Mark - // commands (bold, color…) dispatch transactions directly and are not - // routed through these handlers, so styling the token still works. - handleTextInput: (view, from) => - isInsideTemplateVariable(view.state.doc.resolve(from)), + // Collapsed inserts reported inside a token are boundary artifacts: + // the caret can never rest inside one (see appendTransaction), but + // Chromium binds text typed right after the token into its DOM text + // node. Blocking would swallow the keystroke — let it through, the + // appendTransaction below hoists it back out after the token. Range + // replaces touching a token stay blocked: they would corrupt it. + // Mark commands (bold, color…) dispatch transactions directly and + // are not routed through these handlers, so styling still works. + handleTextInput: (view, from, to) => + from !== to && + (isInsideTemplateVariable(view.state.doc.resolve(from)) || + isInsideTemplateVariable(view.state.doc.resolve(to))), handlePaste: (view) => - isInsideTemplateVariable(view.state.selection.$from), + isInsideTemplateVariable(view.state.selection.$from) || + isInsideTemplateVariable(view.state.selection.$to), handleKeyDown: (view, event) => event.key === "Enter" && isInsideTemplateVariable(view.state.selection.$from), + handleDOMEvents: { + // Virtual keyboards (GBoard, iOS) delete through beforeinput + // events instead of Backspace/Delete keydowns, bypassing the + // keyboard shortcuts above — without this, deleting on mobile + // eats the token one character at a time. Non-cancelable events + // (mid-IME-composition) are left to the default path: the DOM + // edit cannot be prevented, so dispatching our own deletion too + // would delete twice. + beforeinput: (view, event) => { + const backward = event.inputType === "deleteContentBackward"; + if ( + (!backward && event.inputType !== "deleteContentForward") || + !event.cancelable + ) { + return false; + } + if (deleteTemplateVariable(editor, backward)) { + event.preventDefault(); + return true; + } + return false; + }, + // Edits done entirely within an IME composition (e.g. GBoard + // backspacing into the token text, which anchors a composition on + // it) never hit a non-composing transaction, so appendTransaction + // below stays silent the whole time: the token loses characters + // and the caret is left stranded inside it. Normalize once the + // composition settles — deferred a tick so ProseMirror flushes + // the composed DOM into its state first. + compositionend: (view) => { + setTimeout(() => { + if (view.isDestroyed) return; + const tr = tokenIntegrityTransaction( + view.state, + view.state.selection.from, + true, + "rest", + ); + if (tr) view.dispatch(tr); + }, 0); + return false; + }, + }, + }, + // Composition (mobile IME) rewrites the DOM before ProseMirror sees + // anything, so the handlers above cannot protect the token there. + // Restoring mid-composition would fight the IME, so normalization + // waits for the composition to settle: appendTransaction covers every + // non-composing transaction, and compositionend (below, in + // handleDOMEvents) covers edits whose transactions all happened while + // composing. + appendTransaction: (transactions, oldState, newState) => { + const isComposing = () => { + // TipTap proxies a missing view and throws on access; before the + // view is mounted no composition can be ongoing. + try { + return editor.view.composing; + } catch { + return false; + } + }; + if (isComposing()) return null; + const docChanged = transactions.some( + (transaction) => transaction.docChanged, + ); + // Where the caret sat before this batch, in current-doc coordinates + // — the reference the ejection uses to pick a side. + const cameFrom = transactions.reduce( + (pos, transaction) => transaction.mapping.map(pos), + oldState.selection.from, + ); + return tokenIntegrityTransaction( + newState, + cameFrom, + docChanged, + docChanged ? "rest" : "traverse", + ); }, }), ]; diff --git a/src/frontend/src/features/blocknote/inline-template-variable/index.tsx b/src/frontend/src/features/blocknote/inline-template-variable/index.tsx index 7d9f6c77..8412f3ee 100644 --- a/src/frontend/src/features/blocknote/inline-template-variable/index.tsx +++ b/src/frontend/src/features/blocknote/inline-template-variable/index.tsx @@ -2,13 +2,17 @@ import { createReactInlineContentSpec } from "@blocknote/react"; import React, { useMemo } from "react"; import { useBlockNoteEditor, useComponentsContext } from "@blocknote/react"; import { BlockSchema, StyleSchema, Styles, defaultInlineContentSpecs, InlineContentSchemaFromSpecs } from "@blocknote/core"; +import { createPortal } from "react-dom"; import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; import { useTranslation } from "react-i18next"; +import { MobileToolbarButton } from "@/features/blocknote/mobile-toolbar/buttons"; +import { useMobileToolbarDrawer } from "@/features/blocknote/mobile-toolbar/drawer-context"; +import { Drawer } from "@/features/ui/components/drawer"; import { PlaceholderVariable } from "./use-placeholder-variables"; export const TEMPLATE_VARIABLE_TYPE = "template-variable" as const; -export const InlineTemplateVariable = createReactInlineContentSpec( +const InlineTemplateVariableSpec = createReactInlineContentSpec( { type: TEMPLATE_VARIABLE_TYPE, // "styled" (instead of "none") so the `{value}` token is stored as styled @@ -22,12 +26,60 @@ export const InlineTemplateVariable = createReactInlineContentSpec( }, }, { + // The chip DOM must stay editable: the model allows the caret inside the + // token (styled content), and a contenteditable=false island leaves that + // caret with no DOM home — invisible cursor on desktop, dismissed + // keyboard on mobile. spellCheck off discourages mobile IMEs from + // treating the label as a composable word (their compositions bypass the + // editing guards and have to be repaired after the fact). render: ({ contentRef }) => ( - + ), } ); +export const InlineTemplateVariable = { + ...InlineTemplateVariableSpec, + implementation: { + ...InlineTemplateVariableSpec.implementation, + node: InlineTemplateVariableSpec.implementation.node.extend({ + // A token can only ever hold styled text — "styled" maps to "inline*", + // which would let a token nest inside another one on DOM re-parse. + content: "text*", + // TipTap's default NodeView ignoreMutation has an iOS/Android-only + // branch (user-agent sniffed) that lets childList mutations inside the + // NodeView DOM through to ProseMirror. The React NodeView mounts its + // wrappers asynchronously, so on mobile that very mount is seen as a + // foreign mutation: ProseMirror redraws the node view, React remounts + // it, and the app hangs in an endless synchronous commit loop (the + // "insert a variable freezes the app" bug). React owns everything + // inside the chip and its text only changes through transactions, so + // every internal mutation is safe to ignore; selection mutations keep + // the default behavior (node selection still works). + addNodeView() { + const createParentNodeView = this.parent?.(); + if (!createParentNodeView) return null; + return (props) => { + const nodeView = createParentNodeView(props); + if (nodeView && typeof nodeView === "object") { + const defaultIgnoreMutation = + nodeView.ignoreMutation?.bind(nodeView); + nodeView.ignoreMutation = (mutation) => + mutation.type === "selection" + ? (defaultIgnoreMutation?.(mutation) ?? false) + : true; + } + return nodeView; + }; + }, + }), + }, +} as typeof InlineTemplateVariableSpec; + /** * Builds the inline content inserted when picking a variable: the token itself * followed by a trailing space. @@ -59,6 +111,8 @@ type TemplateVariableInlineContentSchema = InlineContentSchemaFromSpecs< typeof defaultInlineContentSpecs & { [TEMPLATE_VARIABLE_TYPE]: typeof InlineTemplateVariable } >; +const VARIABLES_DRAWER_ID = "template-variables"; + type TemplateVariableSelectorProps = { variables: PlaceholderVariable[]; isLoading: boolean; @@ -68,6 +122,9 @@ export const TemplateVariableSelector = ({ variables, isLoading }: TemplateVaria const { t } = useTranslation(); const editor = useBlockNoteEditor(); const Components = useComponentsContext()!; + // Non-null when rendered inside the mobile toolbar: variables are then + // picked from a bottom drawer instead of the desktop inline select. + const mobileDrawer = useMobileToolbarDrawer(); const variableItems = useMemo(() => { return variables.map(({ value, label }) => ({ text: label, @@ -80,6 +137,16 @@ export const TemplateVariableSelector = ({ variables, isLoading }: TemplateVaria }, [editor, variables]); if (isLoading) { + if (mobileDrawer) { + return ( + } + label={t("Loading variables...")} + isDisabled + onClick={() => {}} + /> + ); + } return ( } @@ -94,6 +161,49 @@ export const TemplateVariableSelector = ({ variables, isLoading }: TemplateVaria return null; } + if (mobileDrawer) { + return ( + <> + } + label={t("Variables")} + isActive={mobileDrawer.openId === VARIABLES_DRAWER_ID} + onClick={() => mobileDrawer.open(VARIABLES_DRAWER_ID)} + /> + {mobileDrawer.openId === VARIABLES_DRAWER_ID && + mobileDrawer.slot && + createPortal( + +
+ {variableItems.map((item) => ( + + ))} +
+
, + mobileDrawer.slot, + )} + + ); + } + return ( (); const Components = useComponentsContext()!; const modal = useModal(); + // Non-null when rendered inside the mobile toolbar: templates are then + // picked from a bottom drawer instead of the desktop modal. + const mobileDrawer = useMobileToolbarDrawer(); const hasInlineContent = useEditorState({ editor, @@ -141,6 +150,16 @@ export const MessageTemplateSelector = ({ mailboxId, messageId, ensureDraft, upl if (!hasInlineContent) return null; if (isLoading) { + if (mobileDrawer) { + return ( + } + label={t("Loading templates...")} + isDisabled + onClick={() => {}} + /> + ); + } return ( } @@ -155,6 +174,50 @@ export const MessageTemplateSelector = ({ mailboxId, messageId, ensureDraft, upl return null; } + if (mobileDrawer) { + return ( + <> + } + label={t("Insert template")} + isActive={mobileDrawer.openId === TEMPLATES_DRAWER_ID} + onClick={() => mobileDrawer.open(TEMPLATES_DRAWER_ID)} + /> + {mobileDrawer.openId === TEMPLATES_DRAWER_ID && + mobileDrawer.slot && + createPortal( + +
+ {templates.map((template) => ( + + ))} +
+
, + mobileDrawer.slot, + )} + + ); + } + return ( <> * { + // Buttons keep their natural width — overflow scrolls instead of + // squeezing text buttons (the selectors) down to one letter. + flex-shrink: 0; + scroll-snap-align: start; + } + + // Mantine buttons remaining in the bar (file actions, template and + // signature selectors) get the same touch-target size as the custom + // buttons. + .bn-button { + min-width: 44px; + min-height: 44px; + } + } + + // The bar is portaled to , outside .composer-field-input whose + // scoped rules normally style the separators (see + // blocknote-view-field/_index.scss) — restated here. Declared at bar level + // (not inside .bn-toolbar) so the format panel rows share it. + .bn-toolbar-separator { + flex-shrink: 0; + width: 1px; + align-self: stretch; + margin-block: var(--c--globals--spacings--3xs); + background-color: var(--c--components--forms-input--border-color); + + &:first-child, + &:last-child, + & + .bn-toolbar-separator { + display: none; + } + } + + // The slot children portal their Drawer into: display: contents so the + // portaled Drawer participates directly in the bar's flex column. + .mobile-toolbar__drawer-slot { + display: contents; + } + + .mobile-toolbar__button { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--c--globals--spacings--3xs); + min-width: 44px; + min-height: 44px; + padding: 0 var(--c--globals--spacings--3xs); + border: none; + border-radius: var(--c--globals--spacings--3xs); + background: transparent; + color: var(--c--contextuals--content--semantic--neutral--primary); + font: inherit; + + &:disabled { + opacity: 0.4; + } + + &--active { + background-color: var(--c--contextuals--background--semantic--contextual--primary); + } + } + + .mobile-toolbar__button-text { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--c--globals--font--sizes--2xs); + } + + // The panel lives inside the generic Drawer, which brings the sheet + // chrome (handle, title row, scroll) — only the content layout is here. + .mobile-toolbar__panel { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--2xs); + padding: var(--c--globals--spacings--2xs); + // Keep part of the editor visible on small landscape screens. + max-height: 45vh; + } + + // Shared scroller behavior for every panel row: horizontal overflow with + // the same snap feel as the main buttons row. + .mobile-toolbar__panel-inline, + .mobile-toolbar__panel-row { + display: flex; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; + touch-action: pan-x; + + & > * { + scroll-snap-align: start; + } + } + + .mobile-toolbar__panel-inline { + gap: var(--c--globals--spacings--2xs); + + & > * { + flex: 1 0 min-content; + padding-inline: var(--c--globals--spacings--md); + } + + .mobile-toolbar__button { + width: 100%; + min-height: 56px; + } + } + + .mobile-toolbar__panel-row { + align-items: center; + gap: var(--c--globals--spacings--3xs); + } + + .mobile-toolbar__swatch { + flex-shrink: 0; + width: 44px; + height: 44px; + border: 2px solid var(--c--contextuals--border--surface--primary); + border-radius: 12px; + font-size: var(--c--globals--font--sizes--m); + font-weight: 900; + // The letter's color and the background swatches' fill come from + // BlockNote's [data-style-type][data-value] rules. Neither color nor a + // blanket background is declared here on purpose: those rules only weigh + // (0,2,0), so any declaration at this nesting level would bury them. + + // Only the swatches BlockNote leaves unpainted drop the button's UA + // background. + &[data-style-type="textColor"], + &[data-value="default"] { + background: transparent; + } + + // iOS paints button text in the system blue, not black: pin the letter + // to a neutral token wherever BlockNote's rules don't color it + // (background swatches and the "default" swatch). + &[data-style-type="backgroundColor"], + &[data-value="default"] { + color: var(--c--contextuals--content--semantic--neutral--primary); + } + + &--active { + border-color: var(--c--contextuals--border--semantic--brand--primary); + } + } + + // Chrome (background, border, radius, paddings) comes from the shared + // floating-bar rule above. + .mobile-toolbar__link-editor { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--3xs); + } + + .mobile-toolbar__link-input { + flex: 1 1 auto; + min-width: 0; + height: 44px; + padding: 0 var(--c--globals--spacings--2xs); + border: 1px solid var(--c--components--forms-input--border-color); + border-radius: var(--c--components--forms-input--border-radius); + background: var(--c--contextuals--background--surface--primary); + color: inherit; + font: inherit; + } +} diff --git a/src/frontend/src/features/blocknote/mobile-toolbar/buttons.tsx b/src/frontend/src/features/blocknote/mobile-toolbar/buttons.tsx new file mode 100644 index 00000000..4b187a95 --- /dev/null +++ b/src/frontend/src/features/blocknote/mobile-toolbar/buttons.tsx @@ -0,0 +1,256 @@ +import { + blockHasType, + defaultProps, + DefaultStyleSchema, + editorHasBlockWithType, + Styles, + StyleSchema, +} from "@blocknote/core"; +import { + BlockTypeSelectItem, + blockTypeSelectItems, + useBlockNoteEditor, + useEditorState, +} from "@blocknote/react"; +import clsx from "clsx"; +import { useMemo } from "react"; +import { RiAlignCenter, RiAlignLeft, RiAlignRight } from "react-icons/ri"; + +import { isHiddenBlockTypeSelectItem } from "../utils"; +import { ComposerEditor } from "./hooks"; + +type MobileToolbarButtonProps = { + icon: React.ReactNode; + label: string; + /** Visible text under the icon (format panel grid). */ + text?: string; + isActive?: boolean; + isDisabled?: boolean; + onClick: () => void; +}; + +export const MobileToolbarButton = ({ + icon, + label, + text, + isActive, + isDisabled, + onClick, +}: MobileToolbarButtonProps) => ( + +); + +/** + * Like editor.getActiveStyles(), but aware of ProseMirror's storedMarks — + * the pending marks a toggle sets on a collapsed cursor. getActiveStyles only + * reads the marks of the text at the cursor, so toggling bold with nothing + * selected would leave the button visually inert until text is typed. When + * storedMarks is set it IS the full mark set for the next insertion + * (initialized from the cursor marks, then toggled), so it fully replaces + * getActiveStyles' result rather than merging with it. + */ +export const getEffectiveStyles = ( + editor: ComposerEditor, +): Styles => { + const storedMarks = editor._tiptapEditor.state.storedMarks; + if (!storedMarks) return editor.getActiveStyles(); + + const styles: Record = {}; + // Widened to the base StyleSchema so marks can be looked up by name. + const styleSchema: StyleSchema = editor.schema.styleSchema; + for (const mark of storedMarks) { + const config = styleSchema[mark.type.name]; + if (!config) continue; + styles[config.type] = + config.propSchema === "boolean" ? true : mark.attrs.stringValue; + } + return styles as Styles; +}; + +const STYLE_TOGGLES = { + bold: { bold: true }, + italic: { italic: true }, + underline: { underline: true }, + strike: { strike: true }, +} as const satisfies Record>; + +export type BasicStyle = keyof typeof STYLE_TOGGLES; + +export const StyleToggleButton = ({ + style, + icon, +}: { + style: BasicStyle; + icon: React.ReactNode; +}) => { + const editor = useBlockNoteEditor(); + const isActive = useEditorState({ + editor, + selector: ({ editor }) => style in getEffectiveStyles(editor), + }); + + if (!(style in editor.schema.styleSchema)) return null; + + return ( + editor.toggleStyles(STYLE_TOGGLES[style])} + /> + ); +}; + +/** + * The block-type items available in this composer's schema, minus the ones + * hidden from the UI (same filtering as the desktop BlockTypeSelect). + */ +export const useAvailableBlockTypeItems = (): BlockTypeSelectItem[] => { + const editor = useBlockNoteEditor(); + return useMemo( + () => + blockTypeSelectItems(editor.dictionary) + .filter((item) => !isHiddenBlockTypeSelectItem(item)) + .filter((item) => + editorHasBlockWithType( + editor, + item.type, + Object.fromEntries( + Object.entries(item.props || {}).map( + ([name, value]) => [name, typeof value], + ), + ) as Record, + ), + ), + [editor], + ); +}; + +export const BlockTypeButton = ({ + item, + showText, +}: { + item: BlockTypeSelectItem; + showText?: boolean; +}) => { + const editor = useBlockNoteEditor(); + const selectedBlocks = useEditorState({ + editor, + selector: ({ editor }) => + editor.getSelection()?.blocks || [ + editor.getTextCursorPosition().block, + ], + }); + const firstBlock = selectedBlocks[0]; + const isSelected = + firstBlock.type === item.type && + Object.entries(item.props || {}).every( + ([name, value]) => + (firstBlock.props as Record)[name] === value, + ); + const ItemIcon = item.icon; + + return ( + } + label={item.name} + text={showText ? item.name : undefined} + isActive={isSelected} + onClick={() => { + editor.transact(() => { + // Re-tapping the active type toggles back to paragraph. + // `never` casts because BlockTypeSelectItem carries plain + // strings — same coercion the codebase uses for custom + // block types (see createColumnListBlock). + const update = isSelected + ? { type: "paragraph" as const } + : { type: item.type as never, props: item.props as never }; + for (const block of selectedBlocks) { + editor.updateBlock(block, update); + } + }); + }} + /> + ); +}; + +const ALIGNMENT_ICONS = { + left: RiAlignLeft, + center: RiAlignCenter, + right: RiAlignRight, +} as const; + +export type MobileTextAlignment = keyof typeof ALIGNMENT_ICONS; + +export const TextAlignMobileButton = ({ + alignment, +}: { + alignment: MobileTextAlignment; +}) => { + const editor = useBlockNoteEditor(); + // Same detection as the desktop TextAlignButton, without its table + // handling: table blocks are hidden in this app (see HIDDEN_BLOCK_TYPES). + const state = useEditorState({ + editor, + selector: ({ editor }) => { + const selectedBlocks = editor.getSelection()?.blocks || [ + editor.getTextCursorPosition().block, + ]; + const firstBlock = selectedBlocks[0]; + if ( + !blockHasType(firstBlock, editor, firstBlock.type, { + textAlignment: defaultProps.textAlignment, + }) + ) { + return undefined; + } + return { + textAlignment: (firstBlock.props as Record) + .textAlignment as string, + blocks: selectedBlocks, + }; + }, + }); + + if (!state) return null; + + const AlignIcon = ALIGNMENT_ICONS[alignment]; + return ( + } + label={editor.dictionary.formatting_toolbar[`align_${alignment}`].tooltip} + isActive={state.textAlignment === alignment} + onClick={() => { + editor.transact(() => { + for (const block of state.blocks) { + if ( + blockHasType(block, editor, block.type, { + textAlignment: defaultProps.textAlignment, + }) + ) { + editor.updateBlock(block, { + props: { textAlignment: alignment }, + }); + } + } + }); + }} + /> + ); +}; diff --git a/src/frontend/src/features/blocknote/mobile-toolbar/drawer-context.ts b/src/frontend/src/features/blocknote/mobile-toolbar/drawer-context.ts new file mode 100644 index 00000000..71374700 --- /dev/null +++ b/src/frontend/src/features/blocknote/mobile-toolbar/drawer-context.ts @@ -0,0 +1,32 @@ +import { createContext, useContext } from "react"; + +export type MobileToolbarDrawerApi = { + /** + * Element to portal a Drawer into. Sits above the toolbar row inside the + * fixed bar (styled `display: contents`, so the portaled Drawer joins the + * bar's flex column directly). + */ + slot: HTMLElement | null; + /** Identifier of the currently open child drawer, if any. */ + openId: string | null; + /** + * Opens the child drawer `id`: folds the other toolbar surfaces and + * dismisses the keyboard so the drawer takes its place (same behavior as + * the "Aa" format panel). + */ + open: (id: string) => void; + /** Closes the child drawer and hands focus back to the editor. */ + close: () => void; +}; + +/** + * Provided by MobileToolbar around its children. Toolbar extras (template / + * signature selectors…) use it to detect they render inside the mobile bar — + * a null context means the desktop toolbar — and to open their content as a + * bottom drawer with large touch targets instead of desktop popovers. + */ +export const MobileToolbarDrawerContext = + createContext(null); + +export const useMobileToolbarDrawer = () => + useContext(MobileToolbarDrawerContext); diff --git a/src/frontend/src/features/blocknote/mobile-toolbar/format-panel.tsx b/src/frontend/src/features/blocknote/mobile-toolbar/format-panel.tsx new file mode 100644 index 00000000..f319d83b --- /dev/null +++ b/src/frontend/src/features/blocknote/mobile-toolbar/format-panel.tsx @@ -0,0 +1,217 @@ +import { useBlockNoteEditor, useEditorState } from "@blocknote/react"; +import { Icon, IconSize } from "@gouvfr-lasuite/ui-kit"; +import clsx from "clsx"; +import { useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { RiBold, RiItalic, RiStrikethrough, RiUnderline } from "react-icons/ri"; + +import { + createColumnListBlock, + isInsideColumn, +} from "../column-layout-block/column-layout-insert-button"; +import { cursorHasInlineContent, insertImageBlock } from "../image-upload-button"; +import { ToolbarSeparator } from "../toolbar-separator"; +import { + BlockTypeButton, + getEffectiveStyles, + MobileToolbarButton, + StyleToggleButton, + TextAlignMobileButton, + useAvailableBlockTypeItems, +} from "./buttons"; + +const SWATCH_COLORS = [ + "default", + "gray", + "brown", + "red", + "orange", + "yellow", + "green", + "blue", + "purple", + "pink", +] as const; + +type SwatchColor = (typeof SWATCH_COLORS)[number]; +type SwatchStyleType = "textColor" | "backgroundColor"; + +const ColorSwatchButton = ({ + styleType, + color, +}: { + styleType: SwatchStyleType; + color: SwatchColor; +}) => { + const editor = useBlockNoteEditor(); + const activeColor = useEditorState({ + editor, + selector: ({ editor }) => + getEffectiveStyles(editor)[styleType] ?? "default", + }); + const title = + styleType === "textColor" + ? editor.dictionary.color_picker.text_title + : editor.dictionary.color_picker.background_title; + const dataKey = + styleType === "textColor" + ? 'data-text-color' + : 'data-background-color'; + // Split objects (not a computed key) so each branch keeps the Styles + // typing. + const styles = + styleType === "textColor" + ? { textColor: color } + : { backgroundColor: color }; + + return ( + + ); +}; + +type MobileFormatPanelProps = { + /** Called after an insertion so the toolbar can fold the panel away. */ + onInsert: () => void; +}; + +/** + * The progressive-disclosure panel opened by the "Aa" toolbar button: + * block types, strike, alignment, insertions and text colors. + */ +export const MobileFormatPanel = ({ onInsert }: MobileFormatPanelProps) => { + const { t } = useTranslation(); + const editor = useBlockNoteEditor(); + const availableBlockItems = useAvailableBlockTypeItems(); + // Headings last: paragraph, lists and quote are the frequent picks on + // mobile, so they come first in the scroller. + const blockItems = useMemo( + () => [ + ...availableBlockItems.filter((item) => item.type !== "heading"), + ...availableBlockItems.filter((item) => item.type === "heading"), + ], + [availableBlockItems], + ); + + const canInsertImage = useEditorState({ + editor, + selector: ({ editor }) => + "image" in editor.schema.blockSpecs && cursorHasInlineContent(editor), + }); + const canInsertColumns = useEditorState({ + editor, + selector: ({ editor }) => + "columnList" in editor.schema.blockSpecs && !isInsideColumn(editor), + }); + + // The block-type list can overflow: when the panel opens, bring the + // active item into view ("nearest" leaves the scroller alone when it is + // already visible). + const blockListRef = useRef(null); + useEffect(() => { + blockListRef.current + ?.querySelector('[aria-pressed="true"]') + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, []); + + return ( +
+
+ {blockItems.map((item) => ( + + ))} +
+
+ {/* Bold/italic/underline are repeated from the buttons row: + that row folds away while this panel is open. */} + } /> + } /> + } + /> + } + /> + + {canInsertImage && ( + } + label={t("Insert image")} + onClick={() => { + insertImageBlock(editor); + onInsert(); + }} + /> + )} + {canInsertColumns && ( + + } + label={t("Insert 2 columns")} + onClick={() => { + const currentBlock = editor.getTextCursorPosition().block; + editor.insertBlocks( + [createColumnListBlock()], + currentBlock, + "after", + ); + onInsert(); + }} + /> + )} + {(canInsertImage || canInsertColumns) && } + + + +
+ {"textColor" in editor.schema.styleSchema && ( +
+ {SWATCH_COLORS.map((color) => ( + + ))} +
+ )} + {"backgroundColor" in editor.schema.styleSchema && ( +
+ {SWATCH_COLORS.map((color) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/frontend/src/features/blocknote/mobile-toolbar/hooks.ts b/src/frontend/src/features/blocknote/mobile-toolbar/hooks.ts new file mode 100644 index 00000000..3ef935b2 --- /dev/null +++ b/src/frontend/src/features/blocknote/mobile-toolbar/hooks.ts @@ -0,0 +1,213 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "@blocknote/core"; +import { useCallback, useEffect, useState } from "react"; + +import { NATIVE_LINK_TAP_EVENT } from "../utils"; + +/** + * What `useBlockNoteEditor()` returns without explicit generics. The + * composers use extended schemas, but the mobile toolbar only relies on + * default blocks/styles, so the default typing is both accurate enough + * and the least cast-heavy. + */ +export type ComposerEditor = BlockNoteEditor< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema +>; + +/** + * Where the document focus currently sits, relative to a composer: + * - "composer": inside the editor, the fixed toolbar or one of their popovers + * — the toolbar is in active use. + * - "outside": some other focusable took over (subject field, search…) — the + * toolbar must fold entirely. + * - "none": focus was dropped without a successor, i.e. the on-screen + * keyboard was dismissed. The "Aa" format panel deliberately puts the + * composer in this state (it replaces the keyboard, Apple-Notes style), so + * it is distinct from "outside". + */ +export type ComposerFocusState = "composer" | "outside" | "none"; + +/** + * The editor's ProseMirror DOM element, or null while the view is not + * mounted. The toolbar renders before BlockNoteView's effect mounts the + * view, and tiptap wraps the missing view in a Proxy that throws on any + * property access — so probing it is the only reliable check. + */ +const getEditorDom = (editor: ComposerEditor): HTMLElement | null => { + try { + return editor._tiptapEditor.view.dom as HTMLElement; + } catch { + return null; + } +}; + +/** + * Tracks focus around a composer so the mobile keyboard toolbar can decide + * when to show. Focus is considered "kept" as long as it stays inside the + * editor's container — which also holds the toolbar — so tapping a toolbar + * button doesn't tear it down. + */ +export const useComposerFocusState = ( + editor: ComposerEditor, +): ComposerFocusState => { + const classify = useCallback( + (node: EventTarget | null): ComposerFocusState => { + if (!(node instanceof Node)) return "outside"; + // Before the view is mounted, focus cannot be inside the editor; + // fall through to the portaled-pieces check. + const editorEl = getEditorDom(editor); + const container = editorEl + ? (editorEl.closest(".bn-container") ?? editorEl) + : null; + if (container?.contains(node)) return "composer"; + // The fixed toolbar and BlockNote's popovers (mantine dropdowns: + // template/signature selectors, file caption…) portal outside the + // editor container; keep the toolbar alive while focus is in any + // of them. + const isPortaledToolbarPiece = + node instanceof Element && + !!node.closest('.bn-mobile-formatting-toolbar, [class*="mantine-"][class*="-dropdown"]'); + return isPortaledToolbarPiece ? "composer" : "outside"; + }, + [editor], + ); + + // Seeded from the current focus: the composer may have autofocused + // before the effect below subscribes. + const [focusState, setFocusState] = useState(() => + document.activeElement && document.activeElement !== document.body + ? classify(document.activeElement) + : "none", + ); + + useEffect(() => { + const onFocusIn = (event: FocusEvent) => { + setFocusState(classify(event.target)); + }; + const onFocusOut = (event: FocusEvent) => { + // No successor: focus was dropped entirely (keyboard dismissed or + // programmatic blur). When a successor exists, the paired focusin + // classifies it precisely. + if (event.relatedTarget === null) setFocusState("none"); + }; + document.addEventListener("focusin", onFocusIn); + document.addEventListener("focusout", onFocusOut); + return () => { + document.removeEventListener("focusin", onFocusIn); + document.removeEventListener("focusout", onFocusOut); + }; + }, [classify]); + + return focusState; +}; + +/** + * Turns a tap on a link inside the composer into an edit action. Navigation + * itself is neutralized at editor creation (`links.onClick`, see + * createNativeLinkOptions): BlockNote's default handler `window.open`s the + * href from a ProseMirror `handleClick`, out of reach of DOM interception. + * That handler places the caret at the tap position (ProseMirror skips its + * own placement for consumed clicks) and re-emits the tap as + * NATIVE_LINK_TAP_EVENT — the reliable signal on touch — with a plain click + * listener as fallback (e.g. tap on a link while the ProseMirror click + * handling is bypassed). Either way the cursor sits inside the link when the + * link editor mounts, so it opens prefilled with the tapped URL. + * + * Document-level listeners with lazy DOM resolution: this effect runs before + * BlockNoteView's own effect mounts the ProseMirror view, and the contains() + * check scopes each toolbar to its own editor. + */ +export const useEditLinkOnTap = ( + editor: ComposerEditor, + openLinkEditor: () => void, +) => { + useEffect(() => { + const onLinkTap = (event: Event) => { + if (!(event.target instanceof Element)) return; + const link = event.target.closest("a"); + if (!link) return; + const editorEl = getEditorDom(editor); + if (!editorEl?.contains(link)) return; + event.preventDefault(); + if (editor.isEditable) openLinkEditor(); + }; + document.addEventListener(NATIVE_LINK_TAP_EVENT, onLinkTap, true); + document.addEventListener("click", onLinkTap, true); + return () => { + document.removeEventListener(NATIVE_LINK_TAP_EVENT, onLinkTap, true); + document.removeEventListener("click", onLinkTap, true); + }; + }, [editor, openLinkEditor]); +}; + +/** + * While a panel or drawer replaces the keyboard, taps in the text must move + * the caret without resummoning it: inputmode="none" keeps real focus on the + * editor (visible caret, live selection) but tells the OS not to show a + * keyboard. + */ +export const useKeyboardSuppression = ( + editor: ComposerEditor, + active: boolean, +) => { + useEffect(() => { + if (!active) return; + // No unmounted-view case to handle: a panel can only open from the + // toolbar, which requires a focused (thus mounted) editor. + const dom = getEditorDom(editor); + if (!dom) return; + dom.setAttribute("inputmode", "none"); + return () => dom.removeAttribute("inputmode"); + }, [editor, active]); +}; + +/** + * Hands the editor back to typing after a keyboard-suppressing panel closes. + * inputmode must be restored before refocusing (the close handler runs ahead + * of the suppression effect's cleanup), and a still-focused editor must be + * blurred first — focus() on an already-focused element is a no-op and would + * leave the keyboard hidden. + */ +export const releaseKeyboardToEditor = (editor: ComposerEditor) => { + const dom = getEditorDom(editor); + if (dom) { + dom.removeAttribute("inputmode"); + if ( + document.activeElement instanceof HTMLElement && + dom.contains(document.activeElement) + ) { + document.activeElement.blur(); + } + } + editor.focus(); +}; + +/** + * Keeps the editor focused while tapping around the bar: stolen focus would + * collapse the selection a command applies to, close the keyboard and tear + * the toolbar down. A native capture-phase listener (not a React prop) so it + * runs before the children's own handlers — stopPropagation neutralizes + * BlockNote's Safari workaround that force-focuses mantine triggers on + * mousedown. Text fields (link URL…) legitimately take focus and are exempt. + */ +export const useKeepEditorFocusOnTap = () => + useCallback((bar: HTMLDivElement | null) => { + if (!bar) return; + const onMouseDown = (event: MouseEvent) => { + if ( + event.target instanceof Element && + !event.target.closest("input, textarea, [contenteditable]") + ) { + event.preventDefault(); + event.stopPropagation(); + } + }; + bar.addEventListener("mousedown", onMouseDown, true); + return () => bar.removeEventListener("mousedown", onMouseDown, true); + }, []); diff --git a/src/frontend/src/features/blocknote/mobile-toolbar/index.tsx b/src/frontend/src/features/blocknote/mobile-toolbar/index.tsx new file mode 100644 index 00000000..b4913427 --- /dev/null +++ b/src/frontend/src/features/blocknote/mobile-toolbar/index.tsx @@ -0,0 +1,222 @@ +import { + FileCaptionButton, + FileDeleteButton, + FilePreviewButton, + FileReplaceButton, + FormattingToolbar, + useBlockNoteEditor, + useEditorState, +} from "@blocknote/react"; +import { useCallback, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { RiBold, RiItalic, RiLink, RiUnderline } from "react-icons/ri"; + +import { Drawer } from "@/features/ui/components/drawer"; +import { ToolbarSeparator } from "../toolbar-separator"; +import { + BlockTypeButton, + MobileToolbarButton, + StyleToggleButton, + useAvailableBlockTypeItems, +} from "./buttons"; +import { MobileToolbarDrawerContext } from "./drawer-context"; +import { MobileFormatPanel } from "./format-panel"; +import { + releaseKeyboardToEditor, + useComposerFocusState, + useEditLinkOnTap, + useKeepEditorFocusOnTap, + useKeyboardSuppression, +} from "./hooks"; +import { MobileLinkEditor } from "./link-editor"; +import { Icon } from "@/features/ui/components/icon"; +import { IconSize } from "@gouvfr-lasuite/ui-kit"; +import { FormatText, KeyboardHide } from "@gouvfr-lasuite/ui-kit/icons"; + +type MobileToolbarProps = { + children?: React.ReactNode; +}; + +type MobileToolbarView = "toolbar" | "format" | "link"; + +/** Dismisses the on-screen keyboard by dropping the DOM focus. */ +const dismissKeyboard = () => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } +}; + +/** + * The composer toolbar for the native app, pinned right above the on-screen + * keyboard while the composer is focused (positioning in _index.scss: the + * webview is resized by the keyboard, so a fixed bottom bar lands on top of + * it). A short row of large touch targets for the most frequent actions; + * everything else lives in the "Aa" format panel. + * + * Not BlockNote's ExperimentalMobileFormattingToolbarController: it only + * renders live React while text is *selected* and serves a frozen + * dangerouslySetInnerHTML snapshot otherwise, which freezes loading states + * and dead-ends every button. + */ +export const MobileToolbar = ({ children }: MobileToolbarProps) => { + const { t } = useTranslation(); + const editor = useBlockNoteEditor(); + const focusState = useComposerFocusState(editor); + const keepEditorFocusOnTap = useKeepEditorFocusOnTap(); + const [view, setView] = useState("toolbar"); + // Child drawers: toolbar extras (template / signature selectors…) open + // their content as a bottom drawer through MobileToolbarDrawerContext, + // portaled into a slot that sits above the toolbar row. + const [childDrawerId, setChildDrawerId] = useState(null); + const [drawerSlot, setDrawerSlot] = useState(null); + + const blockItems = useAvailableBlockTypeItems(); + const bulletListItem = blockItems.find((item) => item.type === "bulletListItem"); + const quoteItem = blockItems.find((item) => item.type === "quote"); + const hasLink = useEditorState({ + editor, + selector: ({ editor }) => editor.getSelectedLinkUrl() !== undefined, + }); + // Tapping a link edits it instead of navigating (the default link + // toolbar is disabled on native, see BlockNoteViewField). + const openLinkEditor = useCallback(() => setView("link"), []); + useEditLinkOnTap(editor, openLinkEditor); + // Panels replace the keyboard: while one is open, tapping in the text + // moves the caret but must not resummon the keyboard. + useKeyboardSuppression(editor, view === "format" || childDrawerId !== null); + + const drawerApi = useMemo( + () => ({ + slot: drawerSlot, + openId: childDrawerId, + open: (id: string) => { + setView("toolbar"); + setChildDrawerId(id); + // Apple-Notes style, like the format panel: the drawer takes + // the keyboard's place. + dismissKeyboard(); + }, + close: () => { + setChildDrawerId(null); + // Closing hands back to typing, keyboard included. + releaseKeyboardToEditor(editor); + }, + }), + [drawerSlot, childDrawerId, editor], + ); + + // React to focus moves (state-from-previous-render pattern, not an + // effect: https://react.dev/learn/you-might-not-need-an-effect): + // - focus taken by something else on the page → fold everything; + // - keyboard dismissed while the link editor is open → plain bar again. + // Moving the caret in the text deliberately does NOT fold the format + // panel or the child drawers: they stay open above the returning + // keyboard, and close through their own Drawer chrome. + const [prevFocusState, setPrevFocusState] = useState(focusState); + if (prevFocusState !== focusState) { + setPrevFocusState(focusState); + if (focusState !== "composer" && view === "link") setView("toolbar"); + if (focusState === "outside" && view === "format") setView("toolbar"); + // Child drawers follow the same rules as the format panel. + if (focusState === "outside" && childDrawerId !== null) setChildDrawerId(null); + } + + // The format panel and child drawers replace the dismissed keyboard, so + // they must survive the "none" state their own opening produces. + const isVisible = + focusState === "composer" || + (focusState === "none" && (view === "format" || childDrawerId !== null)); + + if (!isVisible) return null; + + // Portaled to : composer containers can carry a CSS transform + // (e.g. the thread view's reply form), which would silently become the + // containing block of our position: fixed bar and pin it inside the + // form instead of the viewport. The bn-root/bn-mantine classes come + // along because BlockNote scopes its theme variables and component + // styles under them, and the portal leaves the editor's container. + return createPortal( +
+ {view === "format" && ( + { + setView("toolbar"); + // Closing the panel hands back to typing, keyboard + // included. + releaseKeyboardToEditor(editor); + }} + > + setView("toolbar")} /> + + )} + {/* Children portal their own Drawer here (display: contents, so + it joins the bar's flex column right above the toolbar row). */} +
+ {view === "link" && ( + setView("toolbar")} /> + )} + {/* The buttons row folds away while the format panel is open — + the panel's Drawer chrome (close button, swipe, tap back into + the text) handles the way back. */} + {view === "toolbar" && ( + + } + label={t("Hide keyboard")} + // The mousedown guard keeps the editor focused through + // the tap, so the blur here is what dismisses the + // keyboard — and with it the whole toolbar (focus + // state becomes "none" with no panel open). + onClick={dismissKeyboard} + /> + + } + label={t("Formatting options")} + onClick={() => { + setView("format"); + setChildDrawerId(null); + // Apple-Notes style: dismiss the keyboard so the + // panel takes its place instead of stacking on + // top of it. The selection lives in editor state + // and survives the blur. + dismissKeyboard(); + }} + /> + } /> + } /> + } + /> + } + label={editor.dictionary.formatting_toolbar.link.tooltip} + isActive={hasLink} + onClick={() => setView("link")} + /> + {bulletListItem && } + {quoteItem && } + + + + + {children && ( + + + {children} + + )} + + )} +
, + document.body, + ); +}; diff --git a/src/frontend/src/features/blocknote/mobile-toolbar/link-editor.tsx b/src/frontend/src/features/blocknote/mobile-toolbar/link-editor.tsx new file mode 100644 index 00000000..f554784d --- /dev/null +++ b/src/frontend/src/features/blocknote/mobile-toolbar/link-editor.tsx @@ -0,0 +1,89 @@ +import { useBlockNoteEditor } from "@blocknote/react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RiArrowLeftSLine, RiCheckLine, RiLinkUnlinkM } from "react-icons/ri"; + +import { MobileToolbarButton } from "./buttons"; + +const hasScheme = (url: string) => /^[a-z][a-z\d+.-]*:/i.test(url); + +/** + * Replaces the toolbar row with a URL input to create, edit or remove a link + * on the current selection. + */ +export const MobileLinkEditor = ({ onClose }: { onClose: () => void }) => { + const { t } = useTranslation(); + const editor = useBlockNoteEditor(); + // Snapshot on mount: the linked URL if the cursor sits inside a link. + const [initialUrl] = useState(() => editor.getSelectedLinkUrl() ?? ""); + const [url, setUrl] = useState(initialUrl); + + const closeAndRefocus = () => { + editor.focus(); + onClose(); + }; + + const remove = () => { + // deleteLink resolves the full link range from the cursor by itself. + if (initialUrl) editor.deleteLink(); + closeAndRefocus(); + }; + + const apply = () => { + const trimmed = url.trim(); + if (!trimmed) { + remove(); + return; + } + const href = hasScheme(trimmed) ? trimmed : `https://${trimmed}`; + // createLink only marks the selected range: when editing an existing + // link from a collapsed cursor, extend the selection to the whole mark + // first, otherwise the new URL would be applied to nothing. + if (initialUrl) editor._tiptapEditor.commands.extendMarkRange("link"); + const selectedText = editor.getSelectedText(); + editor.createLink(href, selectedText ? undefined : href); + closeAndRefocus(); + }; + + return ( +
+ } + label={t("Cancel")} + onClick={closeAndRefocus} + /> + setUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + apply(); + } + if (event.key === "Escape") closeAndRefocus(); + }} + placeholder={t("Link URL")} + enterKeyHint="done" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> + {initialUrl && ( + } + label={t("Remove link")} + onClick={remove} + /> + )} + } + label={t("Apply")} + isDisabled={!url.trim() && !initialUrl} + onClick={apply} + /> +
+ ); +}; diff --git a/src/frontend/src/features/blocknote/signature-block/index.tsx b/src/frontend/src/features/blocknote/signature-block/index.tsx index 87cecec2..87ffc57c 100644 --- a/src/frontend/src/features/blocknote/signature-block/index.tsx +++ b/src/frontend/src/features/blocknote/signature-block/index.tsx @@ -1,6 +1,10 @@ import { createReactBlockSpec, useBlockNoteEditor, useComponentsContext, useEditorSelectionChange, useEditorChange, useEditorState } from "@blocknote/react"; -import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; +import { IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; import { useCallback, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { MobileToolbarButton } from "@/features/blocknote/mobile-toolbar/buttons"; +import { useMobileToolbarDrawer } from "@/features/blocknote/mobile-toolbar/drawer-context"; +import { Drawer } from "@/features/ui/components/drawer"; import { Props } from "@blocknote/core"; import DomPurify from "dompurify"; import { keepPreviousData } from "@tanstack/react-query"; @@ -9,6 +13,8 @@ import { MessageComposerBlockSchema, MessageComposerInlineContentSchema, Message import { useTranslation } from "react-i18next"; import { MessageComposerHelper } from "@/features/utils/composer-helper"; import { useHtmlWithObjectUrls } from "@/features/blocknote/image-block/use-html-with-object-urls"; +import { Icon } from "@/features/ui/components/icon"; +import { Lock as LockIcon } from "@gouvfr-lasuite/ui-kit/icons"; /** @@ -48,6 +54,8 @@ function replaceLayoutTablesWithDivs(html: string): string { return doc.body.innerHTML; } +const SIGNATURES_DRAWER_ID = "signature-templates"; + type SignatureTemplateSelectorProps = { mailboxId?: string; messageId?: string; @@ -64,6 +72,9 @@ export const SignatureTemplateSelector = ({ mailboxId, messageId, templates = [] const editor = useBlockNoteEditor(); const { t } = useTranslation(); const Components = useComponentsContext()!; + // Non-null when rendered inside the mobile toolbar: signatures are then + // picked from a bottom drawer instead of the desktop inline select. + const mobileDrawer = useMobileToolbarDrawer(); const hasInlineContent = useEditorState({ editor, @@ -95,6 +106,16 @@ export const SignatureTemplateSelector = ({ mailboxId, messageId, templates = [] if (!hasInlineContent) return null; if (isLoading) { + if (mobileDrawer) { + return ( + } + label={t("Loading templates...")} + isDisabled + onClick={() => {}} + /> + ); + } return ( } @@ -110,6 +131,16 @@ export const SignatureTemplateSelector = ({ mailboxId, messageId, templates = [] } if (isForced) { + if (mobileDrawer) { + return ( + } + label={t("This signature is forced")} + isDisabled + onClick={() => {}} + /> + ); + } return } @@ -117,30 +148,30 @@ export const SignatureTemplateSelector = ({ mailboxId, messageId, templates = [] secondaryTooltip={t("You cannot modify it.")} >
- +

{forcedTemplate.name}

; } - return ( - , + icon: , onClick: () => { editor.removeBlocks(["signature"]); }, - }, - ...templates.map((template) => ({ + }, + ...templates.map((template) => ({ text: template.name, isSelected: isSelected === template.id, isDisabled: template.is_forced, - icon: , + icon: template.is_forced + ? + : , onClick: () => { const signatureBlock = editor.getBlock('signature'); @@ -183,8 +214,56 @@ export const SignatureTemplateSelector = ({ mailboxId, messageId, templates = [] MessageComposerHelper.insertSignatureBlock(editor, newBlock); } } - })), - ]} + })), + ]; + + if (mobileDrawer) { + return ( + <> + } + label={t("Signature")} + isActive={!!isSelected || mobileDrawer.openId === SIGNATURES_DRAWER_ID} + onClick={() => mobileDrawer.open(SIGNATURES_DRAWER_ID)} + /> + {mobileDrawer.openId === SIGNATURES_DRAWER_ID && + mobileDrawer.slot && + createPortal( + +
+ {signatureItems.map((item) => ( + + ))} +
+
, + mobileDrawer.slot, + )} + + ); + } + + return ( + ); } diff --git a/src/frontend/src/features/blocknote/toolbar-separator.tsx b/src/frontend/src/features/blocknote/toolbar-separator.tsx new file mode 100644 index 00000000..6fb06f39 --- /dev/null +++ b/src/frontend/src/features/blocknote/toolbar-separator.tsx @@ -0,0 +1,3 @@ +export const ToolbarSeparator = () => ( +
+); diff --git a/src/frontend/src/features/blocknote/toolbar.tsx b/src/frontend/src/features/blocknote/toolbar.tsx index e94459a2..94c77bfb 100644 --- a/src/frontend/src/features/blocknote/toolbar.tsx +++ b/src/frontend/src/features/blocknote/toolbar.tsx @@ -14,18 +14,18 @@ import { } from "@blocknote/react"; import { useMemo } from "react"; +import { isNativePlatform } from "@/features/native/platform"; import { ColumnLayoutInsertButton } from "./column-layout-block/column-layout-insert-button"; import { ImageUploadButton } from "./image-upload-button"; +import { MobileToolbar } from "./mobile-toolbar"; +import { ToolbarSeparator } from "./toolbar-separator"; import { isHiddenBlockTypeSelectItem } from "./utils"; -const ToolbarSeparator = () => ( -
-); - type ToolbarProps = { children?: React.ReactNode; } -export const Toolbar = ({ children }: ToolbarProps) => { + +const DesktopToolbar = ({ children }: ToolbarProps) => { const editor = useBlockNoteEditor(); const filteredItems = useMemo( () => blockTypeSelectItems(editor.dictionary).filter( @@ -38,6 +38,7 @@ export const Toolbar = ({ children }: ToolbarProps) => { + @@ -73,10 +74,21 @@ export const Toolbar = ({ children }: ToolbarProps) => { - - - - {children} + {children && ( + <> + + {children} + + )} - ) + ); +}; + +export const Toolbar = ({ children }: ToolbarProps) => { + // The platform never changes at runtime, so branching to components with + // different hooks is safe. + if (isNativePlatform()) { + return {children}; + } + return {children}; } diff --git a/src/frontend/src/features/blocknote/utils.ts b/src/frontend/src/features/blocknote/utils.ts index 199684a4..196236bb 100644 --- a/src/frontend/src/features/blocknote/utils.ts +++ b/src/frontend/src/features/blocknote/utils.ts @@ -1,8 +1,9 @@ import * as locales from '@blocknote/core/locales'; -import { Block, defaultBlockSpecs } from '@blocknote/core'; +import { Block, BlockNoteEditor, BlockSchema, InlineContentSchema, StyleSchema, defaultBlockSpecs } from '@blocknote/core'; import { TFunction } from 'i18next'; import { ALLOWED_IMAGE_MIME_TYPES } from '@/features/blocknote/image-block'; import { TEMPLATE_VARIABLE_TYPE } from '@/features/blocknote/inline-template-variable'; +import { isNativePlatform } from '@/features/native/platform'; /** * Builds the BlockNote i18n dictionary for the given locale. @@ -16,6 +17,53 @@ export const createBlockNoteDictionary = (locale: string, t: TFunction) => ({ }, }); +/** + * Bubbling custom event re-emitted from the tapped link when the native app + * suppresses BlockNote's open-on-click (see createNativeLinkOptions). + */ +export const NATIVE_LINK_TAP_EVENT = 'blocknote:native-link-tap'; + +/** + * `links` editor options for the native app: BlockNote's built-in click + * handler `window.open`s the href from a ProseMirror `handleClick`, so no + * DOM-level `preventDefault` can stop it — supplying `links.onClick` is the + * documented way to disable it. The tap is re-emitted as a custom event so + * the mobile toolbar can turn it into an edit action (see useEditLinkOnTap); + * ProseMirror's `handleClick` fires on the simulated mouseup, which touch + * guarantees, unlike the synthesized DOM click. + * + * ProseMirror skips its own caret placement when a click handler consumes + * the event, so the caret is placed here from the tap coordinates — the + * link editor reads the URL to edit from the selection. + */ +export const createNativeLinkOptions = () => + isNativePlatform() + ? { + links: { + onClick: ( + event: MouseEvent, + editor: BlockNoteEditor< + BlockSchema, + InlineContentSchema, + StyleSchema + >, + ) => { + const tapped = editor.prosemirrorView?.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + if (tapped) { + editor._tiptapEditor.commands.setTextSelection(tapped.pos); + } + event.target?.dispatchEvent( + new CustomEvent(NATIVE_LINK_TAP_EVENT, { bubbles: true }), + ); + return true; + }, + }, + } + : {}; + /** * Returns TipTap handleDOMEvents handlers that block non-image file * drops and pastes. Used by composers that only accept image uploads diff --git a/src/frontend/src/features/forms/components/message-composer/index.tsx b/src/frontend/src/features/forms/components/message-composer/index.tsx index b5105833..59ac40ed 100644 --- a/src/frontend/src/features/forms/components/message-composer/index.tsx +++ b/src/frontend/src/features/forms/components/message-composer/index.tsx @@ -19,7 +19,7 @@ import { MessageTemplateTypeChoices, useMailboxesMessageTemplatesAvailableList } import { Attachment } from '@/features/api/gen/models/attachment'; import { MessageComposerHelper } from '@/features/utils/composer-helper'; import { SmartTrailingBlock } from '@/features/blocknote/smart-trailing-block'; -import { createBlockNoteDictionary, dropUnsupportedBlocks, SUPPORTED_BLOCK_SPECS } from '@/features/blocknote/utils'; +import { createBlockNoteDictionary, createNativeLinkOptions, dropUnsupportedBlocks, SUPPORTED_BLOCK_SPECS } from '@/features/blocknote/utils'; import { PasteColorSanitizer } from '@/features/blocknote/paste-sanitizer'; import { handle } from '@/features/utils/errors'; import { findOrphanInlineImages } from './orphan-inline-images'; @@ -196,6 +196,7 @@ export const MessageComposer = React.forwardRef(({ dropdownOptions={[ ...(mode !== 'new' ? [{ label: preferredSendMode === PreferSendMode.SEND_AND_ARCHIVE ? t("Send") : t("Send and archive"), - icon: , + icon: preferredSendMode === PreferSendMode.SEND_AND_ARCHIVE + ? + : , callback: form.handleSubmit(() => handleSubmit({ archive: preferredSendMode !== PreferSendMode.SEND_AND_ARCHIVE })), showSeparator: true, }, { @@ -907,7 +911,7 @@ export const MessageForm = forwardRef(({ variant="tertiary" onClick={onClose} aria-label={t("Delete")} - icon={} + icon={} /> )} @@ -919,7 +923,7 @@ export const MessageForm = forwardRef(({ variant="tertiary" onClick={() => handleDeleteMessage(draft.id)} aria-label={t("Delete draft")} - icon={} + icon={} /> ) diff --git a/src/frontend/src/features/forms/components/search-filters-form/_index.scss b/src/frontend/src/features/forms/components/search-filters-form/_index.scss index ea61e3ee..29cfd275 100644 --- a/src/frontend/src/features/forms/components/search-filters-form/_index.scss +++ b/src/frontend/src/features/forms/components/search-filters-form/_index.scss @@ -1,3 +1,5 @@ +@use "../../../../styles/utils" as *; + .search__filters { display: flex; flex-direction: column; @@ -19,6 +21,42 @@ z-index: 0; } +// On mobile the form is rendered inside a fullscreen modal: drop the floating +// dropdown styling and let it flow in the modal body at full width. The submit/ +// reset buttons live in the modal's own sticky footer (rendered outside this +// scroll area), so the form here only holds the fields. +@media screen and (max-width: breakpoint(tablet)) { + // The fullscreen modal already scrolls its body; the forced inner scroll on + // the content wrapper otherwise nests a second scrollbar (the "slight + // scroll" with the sticky close button pinned on top). Let it grow instead. + .c__modal__content:has(.search__filters) { + overflow: visible; + } + + // The default (non-tab) modal nudges its close button by -1.1rem on both + // axes. On a fullscreen mobile modal that overflows the padded scroller + // (horizontal scroll) and pins the button against the top edge. Keep it + // within the scroller padding instead. + .c__modal__scroller:has(.search__filters) .c__modal__close .c__button { + right: 0; + top: 0; + } + + .search__filters { + position: static; + transform: none; + width: 100%; + min-width: 0; + max-width: 100%; + padding: 0; + border: none; + box-shadow: none; + background-color: transparent; + animation: none; + gap: var(--c--globals--spacings--xs); + } +} + @keyframes slide-in { from { opacity: 0; diff --git a/src/frontend/src/features/forms/components/search-filters-form/index.tsx b/src/frontend/src/features/forms/components/search-filters-form/index.tsx index 8de1f9cf..dacb0053 100644 --- a/src/frontend/src/features/forms/components/search-filters-form/index.tsx +++ b/src/frontend/src/features/forms/components/search-filters-form/index.tsx @@ -1,34 +1,43 @@ import { ALL_MESSAGES_FOLDER, MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/components/mailbox-list"; import { SearchHelper } from "@/features/utils/search-helper"; -import { Icon, Label } from "@gouvfr-lasuite/ui-kit"; +import { IconSize, Label } from "@gouvfr-lasuite/ui-kit"; import { Button, Checkbox, Input, Select } from "@gouvfr-lasuite/cunningham-react"; -import { useId, useRef } from "react"; +import { useEffect, useId, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { Icon, IconProps } from "@/features/ui/components/icon"; type SearchFiltersFormProps = { query: string; onChange: (query: string, submit: boolean) => void; + autoFocusText?: boolean; + /** Form id, lets external buttons (e.g. a modal sticky footer) submit/reset it. */ + id?: string; + /** Hide the inline footer when the submit/reset buttons live outside the form. */ + hideFooter?: boolean; } -export const SearchFiltersForm = ({ query, onChange }: SearchFiltersFormProps) => { +export const SearchFiltersForm = ({ query, onChange, autoFocusText = false, id, hideFooter = false }: SearchFiltersFormProps) => { const { t, i18n } = useTranslation(); const starredLabelId = useId(); const formRef = useRef(null); + useEffect(() => { + if (!autoFocusText) return; + const textInput = formRef.current?.elements.namedItem("text"); + if (textInput instanceof HTMLInputElement) textInput.focus(); + }, [autoFocusText]); + const updateQuery = (submit: boolean) => { const formData = new FormData(formRef.current as HTMLFormElement); const query = SearchHelper.serializeSearchFormData(formData, i18n.resolvedLanguage); onChange(query, submit); - formRef.current?.reset(); } const handleSubmit = (event: React.FormEvent) => updateQuery(event.type === 'submit'); const handleChange = () => updateQuery(false); - const handleReset = () => { - onChange('', false); - formRef.current?.reset(); - } + // The native reset event clears the inputs; we only have to drop the query. + const handleReset = () => onChange('', false); const parsedQuery = SearchHelper.parseSearchQuery(query); @@ -43,7 +52,7 @@ export const SearchFiltersForm = ({ query, onChange }: SearchFiltersFormProps) = } return ( -
+ {t("Starred")} :
-
- - -
+ {!hideFooter && ( +
+ + +
+ )} ); }; type FolderOptionProps = { label: string; - icon: string; + icon: IconProps; } const FolderOption = ({ label, icon }: FolderOptionProps) => { return (
- + {label}
); diff --git a/src/frontend/src/features/forms/components/search-input/_index.scss b/src/frontend/src/features/forms/components/search-input/_index.scss index 853415da..b0619a81 100644 --- a/src/frontend/src/features/forms/components/search-input/_index.scss +++ b/src/frontend/src/features/forms/components/search-input/_index.scss @@ -41,6 +41,10 @@ display: grid; place-items: center; cursor: text; + + & > svg { + color: var(--c--contextuals--content--semantic--neutral--tertiary); + } } .search__input { diff --git a/src/frontend/src/features/forms/components/search-input/index.tsx b/src/frontend/src/features/forms/components/search-input/index.tsx index 0bab73eb..221e3a47 100644 --- a/src/frontend/src/features/forms/components/search-input/index.tsx +++ b/src/frontend/src/features/forms/components/search-input/index.tsx @@ -2,13 +2,26 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { useTranslation } from "react-i18next"; import { useUrlSearchParams } from "@/hooks/use-url-search-params"; import { useEffect, useState, useRef } from "react"; -import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react"; import { SearchFiltersForm } from "../search-filters-form"; import { useLayoutContext } from "@/features/layouts/components/layout-context"; import { MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/components/mailbox-list"; -import { Icon } from "@gouvfr-lasuite/ui-kit"; +import { IconSize, useResponsive } from "@gouvfr-lasuite/ui-kit"; +import { Icon } from "@/features/ui/components/icon"; +import { Settings, XMark, Zoom } from "@gouvfr-lasuite/ui-kit/icons"; -export const SearchInput = () => { +const SEARCH_FILTERS_FORM_ID = "search-filters-form"; + +type SearchInputProps = { + /** + * Render a single icon button instead of the search field. Used by the + * native header, which has no room for the field: the button opens the + * same full-screen search form the field would. + */ + compact?: boolean; +} + +export const SearchInput = ({ compact = false }: SearchInputProps) => { const navigate = useNavigate(); const pathname = useLocation({ select: (l) => l.pathname }); const { closeLeftPanel } = useLayoutContext(); @@ -16,6 +29,7 @@ export const SearchInput = () => { const [value, setValue] = useState(searchParams.get('search') || ''); const [showFilters, setShowFilters] = useState(false); const { t } = useTranslation(); + const { isMobile } = useResponsive(); const searchRef = useRef(null); const handleChange = (event: React.ChangeEvent) => { @@ -62,8 +76,10 @@ export const SearchInput = () => { setValue(searchParams.get('search') || ''); }, [searchParams]); - // Add click outside handler + // Add click outside handler (desktop only: the fullscreen modal handles its + // own dismissal and renders outside searchRef). useEffect(() => { + if (isMobile || compact) return; const handleClickOutside = (event: MouseEvent) => { if (!searchRef.current?.contains(event.target as Node)) { setShowFilters(false); @@ -74,14 +90,63 @@ export const SearchInput = () => { return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, []); + }, [isMobile, compact]); + + // Touch viewports (and the native trigger) open the filters as a full-screen + // form rather than a dropdown anchored to the field. + const filtersModal = ( + setShowFilters(false)} + title={t("Search in messages...")} + size={ModalSize.FULL} + stickyFooter + rightActions={ +
+ + +
+ } + > + {showFilters && ( + + )} +
+ ); + + if (compact) { + return ( + <> + + icon={} + aria-label={t("Reset")} + /> )} + {!isMobile && ( + icon={} + aria-label={showFilters ? t("Close filters") : t("Open filters")} + /> + )}
- {showFilters && } + {isMobile ? filtersModal : ( + showFilters && + )}
); } diff --git a/src/frontend/src/features/hooks/use-compact-tab-drill-down.test.tsx b/src/frontend/src/features/hooks/use-compact-tab-drill-down.test.tsx new file mode 100644 index 00000000..5ec786a6 --- /dev/null +++ b/src/frontend/src/features/hooks/use-compact-tab-drill-down.test.tsx @@ -0,0 +1,122 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + Modal, + ModalProvider, + ModalSize, +} from "@gouvfr-lasuite/cunningham-react"; + +import { useCompactTabDrillDown } from "./use-compact-tab-drill-down"; + +// Opts into React's act() support, which the shared setup does not enable since +// most tests here render to static markup rather than mounting. +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const IMPORTS_CONTENT = "imports content"; + +// The workaround leans on Cunningham's internals (a click on the active tab +// button is the only thing that advances its drill-down) and on react-modal +// attaching its portal after our ref runs. Both are load-bearing and invisible +// from the props, so the modal is rendered for real rather than stubbed. +const TabModalFixture = ({ activeTab }: { activeTab?: string }) => { + const drillDownRef = useCompactTabDrillDown(Boolean(activeTab)); + + return ( + + {}} + aria-label="settings" + size={ModalSize.LARGE} + variant="tab" + sidebarTitle={
} + tabs={[ + { id: "general", label: "General", content:

}, + { + id: "imports", + label: "Imports", + content:

{IMPORTS_CONTENT}

, + }, + ]} + activeTab={activeTab ?? ""} + onTabChange={() => {}} + /> + + ); +}; + +const mockViewportWidth = (isCompact: boolean) => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: isCompact, + addEventListener: () => {}, + removeEventListener: () => {}, + })), + ); +}; + +let container: HTMLDivElement; +let root: Root; + +const render = async (element: React.ReactElement) => { + await act(async () => { + root.render(element); + }); + // The click is queued in a microtask so it lands after react-modal has + // attached its portal; let that microtask and the resulting render flush. + await act(async () => {}); +}; + +const isShowingContent = () => + document + .querySelector(".c__modal__tab-layout") + ?.classList.contains("c__modal__tab-layout--show-content") ?? false; + +beforeEach(() => { + container = document.createElement("div"); + // The class react-modal hides from screen readers while a modal is open; + // its provider asserts the element exists. + container.className = "c__app"; + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("useCompactTabDrillDown", () => { + it("opens the compact modal on the preselected tab's content", async () => { + mockViewportWidth(true); + + await render(); + + expect(isShowingContent()).toBe(true); + expect( + document.querySelector('[role="tabpanel"]')?.textContent, + ).toBe(IMPORTS_CONTENT); + }); + + it("leaves the compact modal on the sidebar when no tab is preselected", async () => { + mockViewportWidth(true); + + await render(); + + expect(isShowingContent()).toBe(false); + }); + + it("does not touch the wide layout, which already shows both panes", async () => { + mockViewportWidth(false); + + await render(); + + expect(isShowingContent()).toBe(false); + }); +}); diff --git a/src/frontend/src/features/hooks/use-compact-tab-drill-down.ts b/src/frontend/src/features/hooks/use-compact-tab-drill-down.ts new file mode 100644 index 00000000..e5174d7e --- /dev/null +++ b/src/frontend/src/features/hooks/use-compact-tab-drill-down.ts @@ -0,0 +1,64 @@ +import { useCallback, useRef } from "react"; + +// Below this width Cunningham's tab modal collapses into a sidebar→content +// drill-down (its internal `@media (max-width: 576px)`). Callers read the same +// breakpoint so their open-time tab selection matches the layout actually shown. +export const COMPACT_TAB_MODAL_MEDIA_QUERY = "(max-width: 576px)"; + +/** + * Opens Cunningham's compact tab modal directly on its preselected tab. + * + * In the compact layout the component keeps the sidebar↔content drill-down in + * its own state, initialised to the sidebar and only ever advanced by a click on + * a tab button — so passing `activeTab` preselects the tab but still lands the + * user on the bare sidebar, unlike the wide layout which shows the content pane + * straight away. Nothing in its props drives that view. + * + * Until it derives the view from `activeTab` upstream, we replay the one gesture + * it does listen to: a click on the already-active tab button, whose handler + * switches the view unconditionally (the caller's `onTabChange` sees an + * unchanged id and no-ops). The returned ref must be attached to an element + * rendered inside the modal — it fires exactly when the modal's DOM is + * committed, which no timer can reliably predict. The click itself is deferred + * to a microtask because react-modal only attaches its portal to the document in + * its own `componentDidMount`, which runs after this ref, and events dispatched + * on a detached tree never reach React. + * + * @param enabled whether a tab was preselected — when false the modal keeps its + * default behaviour of opening on the sidebar. + * @returns a ref callback to attach to an element inside the modal. + */ +export const useCompactTabDrillDown = (enabled: boolean) => { + const hasDrilledDownRef = useRef(false); + + return useCallback( + (node: HTMLElement | null) => { + if (!node || hasDrilledDownRef.current || !enabled) { + return; + } + if (!window.matchMedia(COMPACT_TAB_MODAL_MEDIA_QUERY).matches) { + return; + } + hasDrilledDownRef.current = true; + queueMicrotask(() => { + const layout = node.closest(".c__modal__tab-layout"); + const activeTabButton = layout?.querySelector( + '[role="tab"][aria-selected="true"]', + ); + if (!layout || !activeTabButton || !node.isConnected) { + return; + } + // Suppress the sliding transition for this programmatic jump so the + // modal opens straight on the requested tab instead of animating in from + // the sidebar, then hand the animation back for the user's own + // navigation. + layout.style.transition = "none"; + activeTabButton.click(); + requestAnimationFrame(() => { + layout.style.transition = ""; + }); + }); + }, + [enabled], + ); +}; diff --git a/src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx b/src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx index d94c2450..de4bf9bd 100644 --- a/src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx +++ b/src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx @@ -2,7 +2,7 @@ import { MailboxAdmin, MailDomainAdmin, useMaildomainsMailboxesDestroy, useMaild import { ModalMailboxManageAccesses } from "@/features/layouts/components/admin/modal-mailbox-manage-accesses"; import { Banner } from "@/features/ui/components/banner"; import useAbility, { Abilities } from "@/hooks/use-ability"; -import { IconType, DropdownMenu, Icon, IconSize, Spinner, DropdownMenuItem } from "@gouvfr-lasuite/ui-kit"; +import { IconType, DropdownMenu, IconSize, Spinner, DropdownMenuItem } from "@gouvfr-lasuite/ui-kit"; import { Button, DataGrid, Switch, Tooltip, useModals, usePagination } from "@gouvfr-lasuite/cunningham-react"; import { keepPreviousData } from "@tanstack/react-query"; import { useEffect, useState } from "react"; @@ -13,6 +13,8 @@ import { ModalCreateOrUpdateMailbox } from "../modal-create-update-mailbox"; import MailboxHelper from "@/features/utils/mailbox-helper"; import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature"; import { EmptyCell } from "@/features/ui/components/empty-cell"; +import { Icon } from "@/features/ui/components/icon"; +import { Trash } from "@gouvfr-lasuite/ui-kit/icons"; type AdminUserDataGridProps = { domain: MailDomainAdmin; @@ -135,7 +137,7 @@ export const AdminMailboxDataGrid = ({ domain, pagination, searchQuery }: AdminU refetchMailboxes(); addToast( - + {t('Mailbox {{mailbox}} has been deleted successfully.', { mailbox: email })} ); @@ -341,7 +343,7 @@ const ActionsRow = ({ onManageAccess, onResetPassword, onResetTotp, onDelete, on ...(onResetTotp ? [{ icon: , label: t('Reset 2FA'), callback: onResetTotp }] : []), ]; const destructive: DropdownMenuItem = { - icon: , label: t('Delete'), callback: onDelete, variant: 'danger' + icon: , label: t('Delete'), callback: onDelete, variant: 'danger' }; const options = [ ...secondaryActions.map((opt, i) => ({ diff --git a/src/frontend/src/features/layouts/components/admin/signatures-view/signature-data-grid.tsx b/src/frontend/src/features/layouts/components/admin/signatures-view/signature-data-grid.tsx index ccb0d17c..bfc2d96f 100644 --- a/src/frontend/src/features/layouts/components/admin/signatures-view/signature-data-grid.tsx +++ b/src/frontend/src/features/layouts/components/admin/signatures-view/signature-data-grid.tsx @@ -1,4 +1,4 @@ -import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; +import { IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; import { Button, Checkbox, Column, DataGrid, useModal, useModals } from "@gouvfr-lasuite/cunningham-react"; import { useTranslation } from "react-i18next"; import { useState } from "react"; @@ -7,6 +7,8 @@ import { MailDomainAdmin, ReadMessageTemplate, MessageTemplateTypeChoices, useMa import { Banner } from "@/features/ui/components/banner"; import { addToast, ToasterItem } from "@/features/ui/components/toaster"; import { ModalComposeSignature } from "../modal-compose-signature"; +import { Trash } from "@gouvfr-lasuite/ui-kit/icons"; +import { Icon } from "@/features/ui/components/icon"; type SignatureDataGridProps = { domain: MailDomainAdmin; @@ -186,7 +188,7 @@ export const SignatureDataGrid = ({ domain }: SignatureDataGridProps) => {
); -}; +}); export type LabelsPopupProps = { labels: TreeLabel[]; @@ -155,9 +183,6 @@ export type LabelsPopupProps = { onAddLabel: (labelId: string) => void; onDeleteLabel: (labelId: string, labelSlug: string) => void; onCreateLabel: (initialName: string) => void; - // Set to false when a modal stacked above should own Escape — otherwise - // the popup's capture-phase listener races with the modal's and both close. - closeOnEsc?: boolean; } type LabelOption = { @@ -177,12 +202,14 @@ export const LabelsPopup = ({ onAddLabel, onDeleteLabel, onCreateLabel, - closeOnEsc = true, }: LabelsPopupProps) => { const { t } = useTranslation(); + const { isMobile } = useResponsive(); const [searchQuery, setSearchQuery] = useState(''); const totalThreads = threadIds.length; - const position = usePopupPosition(anchorRef, true, (rect) => { + // On mobile the widget renders as a bottom drawer instead: an anchored + // popup is cramped and half-covered by the keyboard there. + const position = usePopupPosition(anchorRef, !isMobile, (rect) => { const top = rect.bottom + 4; return { top, @@ -192,7 +219,6 @@ export const LabelsPopup = ({ }); useEffect(() => { - if (!closeOnEsc) return; const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; e.stopImmediatePropagation(); @@ -200,7 +226,7 @@ export const LabelsPopup = ({ }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); - }, [onClose, closeOnEsc]); + }, [onClose]); const getFlattenLabelOptions = (label: TreeLabel): LabelOption[] => { const children: LabelOption[] = label.children.length > 0 @@ -241,62 +267,79 @@ export const LabelsPopup = ({ } } - if (!position) return null; + if (!isMobile && !position) return null; - // Portal into #__next rather than document.body so the popup shares the - // same stacking context as Cunningham's Modal (rendered via ModalProvider - // inside #__next). Portalling to body places the popup on a higher paint - // layer than anything isolated inside #__next, regardless of z-index. - const portalTarget = document.getElementById('__next') ?? document.body; + const searchInput = ( + } + label={t('Search a label')} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + fullWidth + /> + ); + + const optionsList = ( +
    + {labelsOptions.map((option) => ( +
  • + handleToggle(option)} + label={option.label} + /> +
  • + ))} +
  • + +
  • +
+ ); return createPortal( - <> -
-
-
-

{t('Add labels')}

- } - label={t('Search a label')} - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - fullWidth - /> -
-
    - {labelsOptions.map((option) => ( -
  • - handleToggle(option)} - label={option.label} - /> -
  • - ))} -
  • - -
  • -
-
- , - portalTarget + isMobile ? ( + <> +
+
+ +
{searchInput}
+ {optionsList} +
+
+ + ) : ( + <> +
+
+
+

{t('Add labels')}

+ {searchInput} +
+ {optionsList} +
+ + ), + document.body ); }; diff --git a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-actions/index.tsx b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-actions/index.tsx index c41cda68..5000718d 100644 --- a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-actions/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-actions/index.tsx @@ -1,62 +1,15 @@ -import { useNavigate } from "@tanstack/react-router"; import { useTranslation } from "react-i18next"; import { Button } from "@gouvfr-lasuite/cunningham-react"; -import { useMailboxContext } from "@/features/providers/mailbox"; -import { useLayoutContext } from "@/features/layouts/components/layout-context"; -import useAbility, { Abilities } from "@/hooks/use-ability"; -import { Icon, IconType } from "@gouvfr-lasuite/ui-kit"; -import { useEffect, useRef, useState } from "react"; +import { useRefreshFeedback } from "@/hooks/use-refresh-feedback"; +import { useComposeMessage } from "@/features/message/use-compose-message"; import { TransientTooltip } from "@/features/ui/components/transient-tooltip"; import clsx from "clsx"; - -const MIN_ANIMATION_MS = 700; +import { Icon } from "@/features/ui/components/icon"; export const MailboxPanelActions = () => { const { t } = useTranslation(); - const navigate = useNavigate(); - const { selectedMailbox, refetchMailboxes } = useMailboxContext(); - const { closeLeftPanel } = useLayoutContext(); - const canWriteMessages = useAbility(Abilities.CAN_WRITE_MESSAGES, selectedMailbox); - const [isRefreshing, setIsRefreshing] = useState(false); - const [feedback, setFeedback] = useState(null); - // Snapshot of count_unread_threads at click-time. Read by the effect below - // once isRefreshing flips back to false AND React has committed the new - // mailbox data — at that point we know the delta. - const baselineUnreadCountRef = useRef(null); - - useEffect(() => { - if (isRefreshing || baselineUnreadCountRef.current === null) return; - const currentCount = selectedMailbox?.count_unread_threads ?? 0; - const delta = currentCount - baselineUnreadCountRef.current; - baselineUnreadCountRef.current = null; - setFeedback( - delta > 0 - ? t("{{count}} new message", { count: delta }) - : t("Up to date"), - ); - }, [isRefreshing, selectedMailbox?.count_unread_threads, t]); - - const handleRefresh = async () => { - if (isRefreshing) return; - baselineUnreadCountRef.current = selectedMailbox?.count_unread_threads ?? 0; - setFeedback(null); - setIsRefreshing(true); - try { - await Promise.all([ - refetchMailboxes(), - new Promise((resolve) => window.setTimeout(resolve, MIN_ANIMATION_MS)), - ]); - } finally { - setIsRefreshing(false); - } - }; - - const goToNewMessageForm = (event: React.MouseEvent) => { - event.preventDefault(); - if (!canWriteMessages) return; - closeLeftPanel(); - navigate({ to: '/mailbox/$mailboxId/new', params: { mailboxId: selectedMailbox!.id } }); - }; + const { canWriteMessages, goToNewMessage, selectedMailbox } = useComposeMessage(); + const { isRefreshing, feedback, clearFeedback, refresh } = useRefreshFeedback(); if (!selectedMailbox) return null; @@ -64,9 +17,9 @@ export const MailboxPanelActions = () => {
{link}
diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/autoreplies-view/compose-autoreply-action.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/autoreplies-view/compose-autoreply-action.tsx index 641207a3..658ac024 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/autoreplies-view/compose-autoreply-action.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/autoreplies-view/compose-autoreply-action.tsx @@ -1,27 +1,28 @@ -import { Button, ButtonProps } from "@gouvfr-lasuite/cunningham-react"; -import { Icon } from "@gouvfr-lasuite/ui-kit"; +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { useResponsive } from "@gouvfr-lasuite/ui-kit"; import { useTranslation } from "react-i18next"; import { useModal } from "@gouvfr-lasuite/cunningham-react"; import { Mailbox } from "@/features/api/gen"; import { ModalComposeMailboxAutoreply } from "../modal-compose-mailbox-autoreply"; +import { Icon } from "@/features/ui/components/icon"; +import { Plus } from "@gouvfr-lasuite/ui-kit/icons"; -type ComposeAutoreplyActionProps = { - mailbox: Mailbox; - size?: ButtonProps["size"]; -}; +type ComposeAutoreplyActionProps = { mailbox: Mailbox; }; -export const ComposeAutoreplyAction = ({ mailbox, size }: ComposeAutoreplyActionProps) => { +export const ComposeAutoreplyAction = ({ mailbox }: ComposeAutoreplyActionProps) => { const { t } = useTranslation(); const modal = useModal(); + const { isMobile } = useResponsive(); return ( <> { const modals = useModals(); const queryClient = useQueryClient(); const [isEnabling, setIsEnabling] = useState(false); + const { isMobile } = useResponsive(); // The row whose sign-out is in flight: the spinner must sit on that row // only, while the mutation's pending flag disables every button (one // sign-out at a time). @@ -153,8 +154,8 @@ export const UserDevicesGrid = () => { const result = isNativePlatform() ? await enableNativePush(user?.id) : config.PUSH_VAPID_PUBLIC_KEY - ? await enableWebPush(config.PUSH_VAPID_PUBLIC_KEY, user?.id) - : "unsupported"; + ? await enableWebPush(config.PUSH_VAPID_PUBLIC_KEY, user?.id) + : "unsupported"; if (result === "subscribed" || result === "registered") { await invalidateDevices(); addToast( @@ -168,33 +169,33 @@ export const UserDevicesGrid = () => { // OS app settings (iOS only lets the app prompt once). const messages: Record = isNativePlatform() ? { - denied: t( - "Notifications are blocked. Allow them for this app in your device settings.", - ), - unsupported: t( - "This device does not support notifications.", - ), - registration_failed: t( - "Couldn't register this device for notifications. Check your connection and try again.", - ), - } + denied: t( + "Notifications are blocked. Allow them for this app in your device settings.", + ), + unsupported: t( + "This device does not support notifications.", + ), + registration_failed: t( + "Couldn't register this device for notifications. Check your connection and try again.", + ), + } : { - denied: t( - "Notifications are blocked. Allow them for this site in your browser settings.", - ), - dismissed: t( - "Notification permission was dismissed. Click again to enable.", - ), - unsupported: t( - "This browser does not support notifications.", - ), - registration_failed: t( - "Couldn't start the notification service worker. Reload the page and try again.", - ), - push_service_error: t( - "Couldn't reach the browser's push service. Your network or company firewall may be blocking it. If you use Brave, enable “Use Google services for push messaging” in settings, restart the browser, then try again.", - ), - }; + denied: t( + "Notifications are blocked. Allow them for this site in your browser settings.", + ), + dismissed: t( + "Notification permission was dismissed. Click again to enable.", + ), + unsupported: t( + "This browser does not support notifications.", + ), + registration_failed: t( + "Couldn't start the notification service worker. Reload the page and try again.", + ), + push_service_error: t( + "Couldn't reach the browser's push service. Your network or company firewall may be blocking it. If you use Brave, enable “Use Google services for push messaging” in settings, restart the browser, then try again.", + ), + }; addToast( {messages[result]} @@ -317,7 +318,7 @@ export const UserDevicesGrid = () => { ); }, }, - { + ...(!isMobile ? [{ id: "last_active", headerName: t("Last active"), size: 140, @@ -328,7 +329,7 @@ export const UserDevicesGrid = () => { const ts = row.last_used_at ?? row.created_at; return ts ? new Date(ts).toLocaleDateString() : ""; }, - }, + } as Column] : []), { // Icon buttons only, just wide enough so the name column gets the // reclaimed width. The header still needs a name: an unlabeled @@ -379,7 +380,7 @@ export const UserDevicesGrid = () => { const enableToolbar = canEnableThisDevice ? (
+ aria-label={t("Back to imports list")} + /> {t("Start a new import")} ); diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/imports-view/imports-data-grid.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/imports-view/imports-data-grid.tsx index bb968b9e..b77aade9 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/imports-view/imports-data-grid.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/imports-view/imports-data-grid.tsx @@ -1,4 +1,4 @@ -import { DropdownMenu, DropdownMenuOption, Icon, Spinner } from "@gouvfr-lasuite/ui-kit"; +import { DropdownMenu, DropdownMenuOption, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; import { Button, Column, DataGrid, useModals } from "@gouvfr-lasuite/cunningham-react"; import { useTranslation } from "react-i18next"; import clsx from "clsx"; @@ -19,6 +19,8 @@ import { addToast, ToasterItem } from "@/features/ui/components/toaster"; import { handle } from "@/features/utils/errors"; import { DateHelper } from "@/features/utils/date-helper"; import { isTerminal, STATUS_CANCELLED } from "@/hooks/import-status"; +import { Icon } from "@/features/ui/components/icon"; +import { Pause, Play, Trash } from "@gouvfr-lasuite/ui-kit/icons"; type ImportsDataGridProps = { mailbox: Mailbox; @@ -92,13 +94,13 @@ const RowActions = ({ row, pending, isOpen, onOpenChange, onPause, onResume, onM if (row.is_active !== false) { options.push({ label: t("Pause polling"), - icon: , + icon: , callback: () => onPause(row), }); } else { options.push({ label: t("Resume polling"), - icon: , + icon: , callback: () => onResume(row), }); } @@ -123,7 +125,7 @@ const RowActions = ({ row, pending, isOpen, onOpenChange, onPause, onResume, onM label: isRunning(row) ? t("Cancel import and delete its messages") : t("Delete imported messages"), - icon: , + icon: , callback: () => onCancel(row), variant: "danger", }); diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/create-integration-action.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/create-integration-action.tsx index 0bd0c63d..0834e8b7 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/create-integration-action.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/create-integration-action.tsx @@ -1,5 +1,7 @@ -import { Button, ButtonProps } from "@gouvfr-lasuite/cunningham-react"; -import { Icon } from "@gouvfr-lasuite/ui-kit"; +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { useResponsive } from "@gouvfr-lasuite/ui-kit"; +import { Icon } from "@/features/ui/components/icon"; +import { Plus } from "@gouvfr-lasuite/ui-kit/icons"; import { useTranslation } from "react-i18next"; import { useModal } from "@gouvfr-lasuite/cunningham-react"; import { Mailbox } from "@/features/api/gen"; @@ -7,21 +9,22 @@ import { ModalComposeIntegration } from "../modal-compose-integration"; type CreateIntegrationActionProps = { mailbox: Mailbox; - size?: ButtonProps["size"]; }; -export const CreateIntegrationAction = ({ mailbox, size }: CreateIntegrationActionProps) => { +export const CreateIntegrationAction = ({ mailbox }: CreateIntegrationActionProps) => { const { t } = useTranslation(); const modal = useModal(); + const { isMobile } = useResponsive(); return ( <> { +export const ComposeTemplateAction = ({ mailbox }: ComposeTemplateActionProps) => { const { t } = useTranslation(); const modal = useModal(); + const { isMobile } = useResponsive(); return ( <> { + const { isMobile } = useResponsive(); const { t } = useTranslation(); const config = useConfig(); const [currentChannel, setCurrentChannel] = useState(initialChannel); @@ -176,7 +177,7 @@ export const ModalComposeIntegration = ({ isOpen={isOpen} onClose={onClose} title={getTitle()} - size={ModalSize.LARGE} + size={isMobile ? ModalSize.FULL : ModalSize.LARGE} >
{viewState === "select_type" && ( diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/tags-selector.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/tags-selector.tsx index b0497da7..8570f459 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/tags-selector.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/tags-selector.tsx @@ -1,5 +1,5 @@ import { Mailbox, TreeLabel, ThreadLabel, useLabelsList } from "@/features/api/gen"; -import { Icon, IconType, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; +import { IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; import { Button, Checkbox, Field, Input, LabelledBox, useModal } from "@gouvfr-lasuite/cunningham-react"; import { useState, useMemo, useRef } from "react"; import { createPortal } from "react-dom"; @@ -10,6 +10,8 @@ import { LabelModal } from "@/features/layouts/components/mailbox-panel/componen import { Badge } from "@/features/ui/components/badge"; import { ColorHelper } from "@/features/utils/color-helper"; import { usePopupPosition } from "@/hooks/use-popup-position"; +import { Icon } from "@/features/ui/components/icon"; +import { Plus, TagAdd, XMark, Zoom } from "@gouvfr-lasuite/ui-kit/icons"; type TagsSelectorProps = { mailbox: Mailbox; @@ -152,7 +154,7 @@ export const TagsSelector = ({ mailbox, selectedTags, onTagsChange }: TagsSelect }} aria-label={t('Remove tag')} > - + ); @@ -167,7 +169,7 @@ export const TagsSelector = ({ mailbox, selectedTags, onTagsChange }: TagsSelect e.stopPropagation(); setIsPopupOpen(true); }} - icon={} + icon={} aria-label={t('Add tags')} />
@@ -182,11 +184,11 @@ export const TagsSelector = ({ mailbox, selectedTags, onTagsChange }: TagsSelect style={{ position: 'fixed', top: position.top, left: position.left, maxHeight: position.maxHeight }} >
-

{t('Add tags')}

+

{t('Add tags')}

} + icon={} label={t('Search a tag')} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} @@ -208,9 +210,16 @@ export const TagsSelector = ({ mailbox, selectedTags, onTagsChange }: TagsSelect type="button" color="brand" variant="primary" - onClick={open} + // Closing before opening the modal: Cunningham + // modals carry no z-index (react-modal portaled + // to body), so the popup and its overlay would + // paint above the modal and swallow its clicks. + onClick={() => { + setIsPopupOpen(false); + open(); + }} fullWidth - icon={} + icon={} > {searchQuery && labelsOptions.length === 0 @@ -218,19 +227,19 @@ export const TagsSelector = ({ mailbox, selectedTags, onTagsChange }: TagsSelect : t('Create a new label')} -
, document.body )} + ); }; diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-mailbox-autoreply/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-mailbox-autoreply/index.tsx index bab2eb07..037c0db3 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-mailbox-autoreply/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-mailbox-autoreply/index.tsx @@ -4,7 +4,7 @@ import { RhfCheckbox } from "@/features/forms/components/react-hook-form/rhf-che import { RhfSelect } from "@/features/forms/components/react-hook-form/rhf-select"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react"; -import { Spinner } from "@gouvfr-lasuite/ui-kit"; +import { Spinner, useResponsive } from "@gouvfr-lasuite/ui-kit"; import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; @@ -28,6 +28,7 @@ type ModalComposeMailboxAutoreplyProps = { } export const ModalComposeMailboxAutoreply = ({ isOpen, onClose, mailbox, autoreply }: ModalComposeMailboxAutoreplyProps) => { + const { isMobile } = useResponsive(); const { t } = useTranslation(); const queryClient = useQueryClient(); const [isDirty, setIsDirty] = useState(false); @@ -58,7 +59,7 @@ export const ModalComposeMailboxAutoreply = ({ isOpen, onClose, mailbox, autorep
diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-mailbox-signature/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-mailbox-signature/index.tsx index 40c39503..34be2486 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-mailbox-signature/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-mailbox-signature/index.tsx @@ -3,7 +3,7 @@ import { RhfInput } from "@/features/forms/components/react-hook-form/rhf-input" import { RhfCheckbox } from "@/features/forms/components/react-hook-form/rhf-checkbox"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react"; -import { Spinner } from "@gouvfr-lasuite/ui-kit"; +import { Spinner, useResponsive } from "@gouvfr-lasuite/ui-kit"; import { FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; @@ -29,6 +29,7 @@ type ModalComposeMailboxSignatureProps = { } export const ModalComposeMailboxSignature = ({ isOpen, onClose, mailbox, signature }: ModalComposeMailboxSignatureProps) => { + const { isMobile } = useResponsive(); const { t } = useTranslation(); const queryClient = useQueryClient(); const [isDirty, setIsDirty] = useState(false); @@ -54,7 +55,7 @@ export const ModalComposeMailboxSignature = ({ isOpen, onClose, mailbox, signatu
diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/index.tsx index e773ea23..f1eec04f 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/index.tsx @@ -2,7 +2,7 @@ import { Mailbox, ReadMessageTemplate, MessageTemplateTypeChoices, useMailboxesM import { RhfInput } from "@/features/forms/components/react-hook-form/rhf-input"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react"; -import { Spinner } from "@gouvfr-lasuite/ui-kit"; +import { Spinner, useResponsive } from "@gouvfr-lasuite/ui-kit"; import { FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; @@ -29,6 +29,7 @@ type ModalComposeTemplateProps = { } export const ModalComposeTemplate = ({ isOpen, onClose, mailbox, template }: ModalComposeTemplateProps) => { + const { isMobile } = useResponsive(); const { t } = useTranslation(); const queryClient = useQueryClient(); const [isDirty, setIsDirty] = useState(false); @@ -53,7 +54,7 @@ export const ModalComposeTemplate = ({ isOpen, onClose, mailbox, template }: Mod
diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss index c2de6d08..af2e4a4b 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss @@ -51,9 +51,9 @@ &__section-header { display: flex; justify-content: space-between; - align-items: baseline; - gap: var(--c--globals--spacings--s); - margin-bottom: var(--c--globals--spacings--xs); + align-items: center; + gap: var(--c--globals--spacings--2xs); + margin-bottom: var(--c--globals--spacings--base); } &__section-title { diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/access-tab/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/access-tab/index.tsx index 1153f8b0..9c6322b8 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/access-tab/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/access-tab/index.tsx @@ -1,5 +1,6 @@ import { Button } from "@gouvfr-lasuite/cunningham-react"; -import { Icon, ShareMemberItem, ShareModal } from "@gouvfr-lasuite/ui-kit"; +import { ShareMemberItem, ShareModal, useResponsive } from "@gouvfr-lasuite/ui-kit"; +import { Icon } from "@/features/ui/components/icon"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -14,6 +15,7 @@ import { useMailboxAccessManagement, } from "@/hooks/use-mailbox-access-management"; import { ResourceSectionHeader } from "../resource-section-header"; +import { UserAdd } from "@gouvfr-lasuite/ui-kit/icons"; type MailboxSettingsAccessTabProps = { mailbox: Mailbox; @@ -26,6 +28,7 @@ export const MailboxSettingsAccessTab = ({ const { user } = useAuth(); const { refetchMailboxes } = useMailboxContext(); const [isAddMemberOpen, setIsAddMemberOpen] = useState(false); + const { isMobile } = useResponsive(); // Only mailbox admins reach this tab (the settings modal filters on // `manage_accesses`), so the accesses list is always fetched. `noGlobalError` @@ -80,11 +83,12 @@ export const MailboxSettingsAccessTab = ({ })} action={ } /> diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/autoreplies-tab/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/autoreplies-tab/index.tsx index 582305f3..7633cc35 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/autoreplies-tab/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/autoreplies-tab/index.tsx @@ -34,7 +34,7 @@ export const MailboxSettingsAutorepliesTab = ({ ? t("No auto-reply") : t("{{count}} auto-reply", { count }) } - action={} + action={} /> diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/imports-tab/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/imports-tab/index.tsx index 8a979ab8..be3c6722 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/imports-tab/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/imports-tab/index.tsx @@ -1,5 +1,5 @@ import { Button } from "@gouvfr-lasuite/cunningham-react"; -import { Icon } from "@gouvfr-lasuite/ui-kit"; +import { useResponsive } from "@gouvfr-lasuite/ui-kit"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -7,6 +7,8 @@ import { getMailboxesImportsListQueryKey, useMailboxesImportsList, } from "@/features/api/gen"; +import { Icon } from "@/features/ui/components/icon"; +import { Plus } from "@gouvfr-lasuite/ui-kit/icons"; import { addToast, ToasterItem } from "@/features/ui/components/toaster"; import { ImportsDataGrid } from "../../imports-view/imports-data-grid"; import { ImportNewView } from "../../imports-view/import-new-view"; @@ -30,6 +32,7 @@ export const MailboxSettingsImportsTab = ({ }: MailboxSettingsImportsTabProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); + const { isMobile } = useResponsive(); const { data } = useMailboxesImportsList(mailbox.id, { query: { enabled: !!mailbox.id }, }); @@ -78,11 +81,12 @@ export const MailboxSettingsImportsTab = ({ } action={ } /> diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx index 4af8dacd..1aabec75 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx @@ -3,7 +3,7 @@ import { ModalSize, ModalTab, } from "@gouvfr-lasuite/cunningham-react"; -import { HorizontalSeparator } from "@gouvfr-lasuite/ui-kit"; +import { HorizontalSeparator, useResponsive } from "@gouvfr-lasuite/ui-kit"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMailboxContext } from "@/features/providers/mailbox"; @@ -14,6 +14,10 @@ import { useConfirmUnsavedChanges, } from "@/features/hooks/use-confirm-before-close"; import { useRestoreFocusOnNestedModalClose } from "@/features/hooks/use-restore-focus-on-nested-modal-close"; +import { + COMPACT_TAB_MODAL_MEDIA_QUERY, + useCompactTabDrillDown, +} from "@/features/hooks/use-compact-tab-drill-down"; import { MailboxSettingsGeneralTab } from "./general-tab"; import { MailboxSettingsAccessTab } from "./access-tab"; import { MailboxSettingsSignaturesTab } from "./signatures-tab"; @@ -43,12 +47,6 @@ type ModalMailboxSettingsProps = { export const MODAL_MAILBOX_SETTINGS_ID = "modal-mailbox-settings"; -// Below this width Cunningham's tab modal collapses into a sidebar→content -// drill-down (its internal `@media (max-width: 576px)`). We read the same -// breakpoint so the open-time tab selection matches the layout actually shown — -// the sidebar↔content view itself can't be driven from outside the component. -const COMPACT_MODAL_MEDIA_QUERY = "(max-width: 576px)"; - /** * Settings modal for a mailbox the user can configure. Built on Cunningham's * "tab" modal layout: the sidebar lists setting categories (General — rename —, @@ -70,6 +68,7 @@ export const ModalMailboxSettings = ({ initialTab, initialImportsView, }: ModalMailboxSettingsProps) => { + const { isMobile } = useResponsive(); const { t } = useTranslation(); const { mailboxes, selectedMailbox } = useMailboxContext(); const isIntegrationsEnabled = useFeatureFlag(FEATURE_KEYS.MAILBOX_ADMIN_CHANNELS); @@ -108,6 +107,10 @@ export const ModalMailboxSettings = ({ // keep a focus anchor and Escape keeps working. useRestoreFocusOnNestedModalClose(isOpen); + // Cunningham's compact layout would otherwise open on the bare sidebar even + // with `initialTab` set; this drills straight into the requested tab's content. + const drillDownRef = useCompactTabDrillDown(Boolean(initialTab)); + const settingsMailbox = settingsMailboxes.find((mailbox) => mailbox.id === selectedMailboxId) ?? (selectedMailbox?.abilities.manage_accesses || @@ -132,7 +135,7 @@ export const ModalMailboxSettings = ({ } if ( typeof window !== "undefined" && - window.matchMedia(COMPACT_MODAL_MEDIA_QUERY).matches + window.matchMedia(COMPACT_TAB_MODAL_MEDIA_QUERY).matches ) { return ""; } @@ -362,9 +365,13 @@ export const ModalMailboxSettings = ({ isOpen={isOpen} aria-label={t("Settings")} onClose={guardedOnClose} - size={ModalSize.LARGE} + size={isMobile ? ModalSize.FULL : ModalSize.LARGE} variant="tab" - sidebarTitle={
{sidebarHeader}
} + sidebarTitle={ +
+ {sidebarHeader} +
+ } tabs={tabs} activeTab={activeTab} onTabChange={async (tabId) => { diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/integrations-tab/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/integrations-tab/index.tsx index b688cbbf..73e68401 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/integrations-tab/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/integrations-tab/index.tsx @@ -28,7 +28,7 @@ export const MailboxSettingsIntegrationsTab = ({ ? t("No integration") : t("{{count}} integration", { count }) } - action={} + action={} /> diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/message-templates-tab/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/message-templates-tab/index.tsx index 140e594c..0c72cc43 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/message-templates-tab/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/message-templates-tab/index.tsx @@ -34,7 +34,7 @@ export const MailboxSettingsMessageTemplatesTab = ({ ? t("No message template") : t("{{count}} message template", { count }) } - action={} + action={} /> diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/signatures-tab/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/signatures-tab/index.tsx index f19956f6..977ac0ed 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/signatures-tab/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/signatures-tab/index.tsx @@ -34,7 +34,7 @@ export const MailboxSettingsSignaturesTab = ({ ? t("No signatures") : t("{{count}} signature", { count }) } - action={} + action={} /> diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/signatures-view/compose-signature-action.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/signatures-view/compose-signature-action.tsx index 787663a1..108e28dc 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/signatures-view/compose-signature-action.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/signatures-view/compose-signature-action.tsx @@ -1,27 +1,30 @@ -import { Button, ButtonProps } from "@gouvfr-lasuite/cunningham-react"; -import { Icon } from "@gouvfr-lasuite/ui-kit"; +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { useResponsive } from "@gouvfr-lasuite/ui-kit"; import { useTranslation } from "react-i18next"; import { useModal } from "@gouvfr-lasuite/cunningham-react"; import { Mailbox } from "@/features/api/gen"; import { ModalComposeMailboxSignature } from "../modal-compose-mailbox-signature"; +import { Icon } from "@/features/ui/components/icon"; +import { Plus } from "@gouvfr-lasuite/ui-kit/icons"; type ComposeSignatureActionProps = { mailbox: Mailbox; - size?: ButtonProps["size"]; }; -export const ComposeSignatureAction = ({ mailbox, size }: ComposeSignatureActionProps) => { +export const ComposeSignatureAction = ({ mailbox }: ComposeSignatureActionProps) => { const { t } = useTranslation(); const modal = useModal(); + const { isMobile } = useResponsive(); return ( <> { - const { t } = useTranslation(); - const { isDesktop } = useResponsive(); - return (
-
-
{leftIcon}
- {isDesktop && ( - <> - - - - )} - + + { !isNativePlatform() && }
); diff --git a/src/frontend/src/features/layouts/components/main/header/authenticated.tsx b/src/frontend/src/features/layouts/components/main/header/authenticated.tsx index ed28ce83..e884af09 100644 --- a/src/frontend/src/features/layouts/components/main/header/authenticated.tsx +++ b/src/frontend/src/features/layouts/components/main/header/authenticated.tsx @@ -1,6 +1,6 @@ -import { DropdownMenu, HeaderProps, Icon, useResponsive, UserMenu, VerticalSeparator } from "@gouvfr-lasuite/ui-kit"; -import { Controls, GearRounded, Upload } from "@gouvfr-lasuite/ui-kit/icons"; -import { Button, Tooltip, useCunningham } from "@gouvfr-lasuite/cunningham-react"; +import { DropdownMenu, HeaderProps, useResponsive, UserMenu, VerticalSeparator } from "@gouvfr-lasuite/ui-kit"; +import { Controls, GearRounded, LeftPanel, Upload, XMark } from "@gouvfr-lasuite/ui-kit/icons"; +import { Button, Tooltip } from "@gouvfr-lasuite/cunningham-react"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "@tanstack/react-router"; @@ -21,6 +21,8 @@ import { useOpenImporter } from "@/features/layouts/components/mailbox-settings/ import { MODAL_NOTIFICATIONS_ID } from "@/features/layouts/components/notifications-settings/modal-notifications"; import { useModalStore } from "@/features/providers/modal-store"; import { useConfig } from "@/features/providers/config"; +import { isNativePlatform } from "@/features/native/platform"; +import { Icon } from "@/features/ui/components/icon"; type AuthenticatedHeaderProps = HeaderProps & { @@ -33,8 +35,14 @@ export const AuthenticatedHeader = ({ isPanelOpen, hideSearch = false, }: AuthenticatedHeaderProps) => { - const { t } = useCunningham(); + const { t } = useTranslation(); const { isDesktop } = useResponsive(); + // The native app has no room for the search field in its single-row header: + // it exposes a trigger in the trailing slot that opens the search form + // full-screen, and the field itself is never mounted. + const isNative = isNativePlatform(); + const showSearchField = !hideSearch && !isNative; + const showSearchTrigger = !hideSearch && isNative; return (
@@ -45,22 +53,21 @@ export const AuthenticatedHeader = ({ aria-label={isPanelOpen ? t("Close the menu") : t("Open the menu")} color="brand" variant="tertiary" - icon={ - - } + icon={} />
{leftIcon}
-
- {!hideSearch && } -
- {isDesktop && ( -
- + {showSearchField && ( +
+
)} +
+ {showSearchTrigger && } + {isDesktop && } +
); }; @@ -154,14 +161,14 @@ const ImportIndicator = () => { ); const progress = withTotal.length ? Math.min( - 99, - Math.round( - withTotal.reduce( - (sum, r) => sum + (r.progress ?? 0) * (r.total_messages ?? 0), - 0, - ) / totalMessages, - ), - ) + 99, + Math.round( + withTotal.reduce( + (sum, r) => sum + (r.progress ?? 0) * (r.total_messages ?? 0), + 0, + ) / totalMessages, + ), + ) : null; return ( @@ -198,7 +205,7 @@ export const HeaderRight = () => { {isDesktop && } - + {!isNativePlatform() && }
{ } : null} logout={logout} termOfServiceUrl={themeConfig.terms_of_service_url} + withMobileView={false} actions={
@@ -222,7 +230,8 @@ const ApplicationMenu = () => { const { openModal } = useModalStore(); const openImporter = useOpenImporter(); const { selectedMailbox } = useMailboxContext(); - const canAccessDomainAdmin = useAbility(Abilities.CAN_VIEW_DOMAIN_ADMIN); + const isMobile = isNativePlatform(); + const canAccessDomainAdmin = useAbility(Abilities.CAN_VIEW_DOMAIN_ADMIN) && !isMobile; const canImportMessages = useAbility(Abilities.CAN_IMPORT_MESSAGES, selectedMailbox); const canManageMessageTemplates = useAbility(Abilities.CAN_MANAGE_MESSAGE_TEMPLATES, selectedMailbox); const isIntegrationsEnabled = useFeatureFlag(FEATURE_KEYS.MAILBOX_ADMIN_CHANNELS); @@ -263,37 +272,37 @@ const ApplicationMenu = () => { return ( <> - , - callback: () => openModal(MODAL_MAILBOX_SETTINGS_ID), - showSeparator: canAccessDomainAdmin && !canImportMessages - }] : []), - ...(canImportMessages ? [importMessageOption] : []), - ...(canManageNotifications ? [{ - label: t("Notifications"), - icon: , - callback: () => openModal(MODAL_NOTIFICATIONS_ID), - showSeparator: canAccessDomainAdmin, - }] : []), - ...(canAccessDomainAdmin ? [{ - label: t("Domain admin"), - icon: , - callback: () => navigate({ to: "/domain" }), - }] : []), - ]} + , + callback: () => openModal(MODAL_MAILBOX_SETTINGS_ID), + showSeparator: canAccessDomainAdmin && !canImportMessages + }] : []), + ...(canImportMessages ? [importMessageOption] : []), + ...(canManageNotifications ? [{ + label: t("Notifications"), + icon: , + callback: () => openModal(MODAL_NOTIFICATIONS_ID), + showSeparator: canAccessDomainAdmin, + }] : []), + ...(canAccessDomainAdmin ? [{ + label: t("Domain admin"), + icon: , + callback: () => navigate({ to: "/domain" }), + }] : []), + ]} > -
+ ); +}; + +export default MobileThreadHeader; diff --git a/src/frontend/src/features/layouts/components/mobile/thread-toolbar/_index.scss b/src/frontend/src/features/layouts/components/mobile/thread-toolbar/_index.scss new file mode 100644 index 00000000..4502d892 --- /dev/null +++ b/src/frontend/src/features/layouts/components/mobile/thread-toolbar/_index.scss @@ -0,0 +1,73 @@ +@use "../../../../../styles/utils" as *; + +.mobile-thread-toolbar { + // Thread management on the left (thumb side), quick-reply CTA on the right. + justify-content: space-between; + // Sit a little lower than the other bottom bar: trim the extra bottom padding + // so the toolbar hugs the screen edge (still clearing the home indicator). + padding-bottom: calc(var(--c--globals--spacings--3xs) + var(--safe-area-inset-bottom)); + + // Single shared box for the management actions: the buttons inside stay + // flat, the group carries the floating-control surface. + &__group { + @include control-surface; + + display: flex; + align-items: center; + gap: var(--c--globals--spacings--3xs); + padding: var(--c--globals--spacings--3xs); + } + + .c__button { + flex: 0 0 auto; + width: 40px; + height: 40px; + } + + &__group .c__button { + // The group's padding already provides the breathing room; keep the + // buttons compact so the box stays a single visual unit. + width: 36px; + height: 36px; + } + + // Icon-only CTA carrying its own brand background: only the floating + // shape applies. + &__reply.c__button { + @include control-shape; + } +} + +// Same overlay + fixed-bottom wrapper pattern as the labels widget drawer: +// the Drawer component renders in place, the wrapper pins it to the viewport +// bottom above the scrim. +.mobile-thread-toolbar__drawer-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background-color: rgb(0 0 0 / 30%); +} + +.mobile-thread-toolbar__drawer { + position: fixed; + inset: auto 0 0 0; + z-index: 10000; + + .drawer { + max-height: min(80dvh, 560px); + } + + .drawer__body { + display: flex; + flex-direction: column; + padding-bottom: var(--safe-area-inset-bottom); + } +} + +.mobile-thread-toolbar__drawer-separator { + align-self: stretch; + height: 1px; + margin: var(--c--globals--spacings--3xs) var(--c--globals--spacings--2xs); + border: none; + background-color: var(--c--contextuals--border--surface--primary); +} diff --git a/src/frontend/src/features/layouts/components/mobile/thread-toolbar/index.tsx b/src/frontend/src/features/layouts/components/mobile/thread-toolbar/index.tsx new file mode 100644 index 00000000..1351a2f8 --- /dev/null +++ b/src/frontend/src/features/layouts/components/mobile/thread-toolbar/index.tsx @@ -0,0 +1,303 @@ +import { Fragment, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { IconType } from "@gouvfr-lasuite/ui-kit"; +import { Thread } from "@/features/api/gen/models"; +import { useMailboxContext } from "@/features/providers/mailbox"; +import { useThreadViewContext } from "@/features/layouts/components/thread-view/provider"; +import { LabelsWidget, LabelsWidgetHandle } from "@/features/layouts/components/labels-widget"; +import { Drawer } from "@/features/ui/components/drawer"; +import useArchive from "@/features/message/use-archive"; +import useTrash from "@/features/message/use-trash"; +import useSpam from "@/features/message/use-spam"; +import useDeleteDrafts from "@/features/message/use-delete-drafts"; +import useStarred from "@/features/message/use-starred"; +import useRead from "@/features/message/use-read"; +import useCopyDeepLink from "@/features/message/use-copy-deep-link"; +import useLeaveThread from "@/features/message/use-leave-thread"; +import useAbility, { Abilities } from "@/hooks/use-ability"; +import ViewHelper from "@/features/utils/view-helper"; +import { isNativePlatform } from "@/features/native/platform"; +import { MobileBottomBar } from "../bottom-bar"; +import { Archive, Link, Restore, Star, StarFilled, TagAdd, Trash } from "@gouvfr-lasuite/ui-kit/icons"; +import { Icon, IconProps } from "@/features/ui/components/icon"; + +/** Modes offered by the quick-reply CTA (bottom-right of the toolbar). */ +export type QuickReplyMode = "reply" | "reply_all"; + +type MobileThreadToolbarProps = { + thread: Thread; + isArchived: boolean; + isTrashed: boolean; + /** + * Mode of the quick-reply CTA, derived from the latest message: reply-all + * when it has several recipients. Null when that message cannot be replied + * to (draft, trashed, or a reply draft is already open). + */ + quickReplyMode: QuickReplyMode | null; + /** Opens the thread accesses modal owned by the thread view. */ + onOpenAccesses: () => void; +}; + +type DrawerAction = { + key: string; + label: string; + icon: IconProps; + onSelect: () => void; + /** Draw a separator above this item — marks the start of an action group. */ + separatorBefore?: boolean; +}; + +/** + * Native-only bottom toolbar shown while a conversation is open. Thread + * management actions (trash/draft deletion, archive, more-options drawer) + * sit on the left, the quick-reply CTA on the right. The drawer gathers + * every available action, including those already surfaced as buttons. + */ +export const MobileThreadToolbar = ({ thread, isArchived, isTrashed, quickReplyMode, onOpenAccesses }: MobileThreadToolbarProps) => { + const { t } = useTranslation(); + const { selectedMailbox, unselectThread } = useMailboxContext(); + const { requestReply, isMessageFormFocused } = useThreadViewContext(); + const { markAsArchived, markAsUnarchived } = useArchive(); + const { markAsTrashed, markAsUntrashed } = useTrash(); + const { markAsSpam, markAsNotSpam } = useSpam(); + const { deleteDrafts } = useDeleteDrafts(); + const { markAsStarred, markAsUnstarred } = useStarred(); + const { markAsReadAt } = useRead(); + const copyDeepLink = useCopyDeepLink(); + const { canLeaveThread, leaveThread } = useLeaveThread(); + const canSendMessages = useAbility(Abilities.CAN_SEND_MESSAGES, selectedMailbox); + const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, thread); + const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox); + const labelsWidgetRef = useRef(null); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const isDraftsView = ViewHelper.isDraftsView(); + + // While the composer is focused the BlockNote formatting toolbar takes the + // spot above the keyboard, so step aside to avoid stacking two bars there. + if (!isNativePlatform() || isMessageFormFocused) return null; + + const canReply = canSendMessages && canEditThread && !isTrashed && quickReplyMode !== null; + // Same rules as the desktop action bar: archiving or reporting spam + // requires edit rights and is irrelevant from the trash or drafts views; + // archiving a spam thread makes no sense either. + const canArchive = canEditThread && !isTrashed && !thread.is_spam && !isDraftsView; + const canReportSpam = canEditThread && !isTrashed && !isDraftsView; + // Starring a trashed or spam thread makes no sense (same rule as the + // subject's star toggle). + const canStar = !isTrashed && !thread.is_spam; + const isStarred = thread.has_starred; + + const handleArchive = () => { + const mutation = isArchived ? markAsUnarchived : markAsArchived; + mutation({ threadIds: [thread.id], onSuccess: unselectThread }); + }; + + // The left "remove" slot follows the context: deleting the draft from the + // drafts view, restoring from the trash, trashing everywhere else. + const removeAction = !canEditThread ? null : isDraftsView + ? { + label: t("Delete draft"), + icon: { name: "edit_off", type: IconType.OUTLINED}, + onSelect: () => deleteDrafts({ threadIds: [thread.id], onSuccess: unselectThread }), + } + : isTrashed + ? { + label: t("Undelete"), + icon: { icon:Restore }, + onSelect: () => markAsUntrashed({ threadIds: [thread.id], onSuccess: unselectThread }), + } + : { + label: t("Move to trash"), + icon: { icon: Trash }, + onSelect: () => markAsTrashed({ threadIds: [thread.id], onSuccess: unselectThread }), + }; + + const hasUnread = thread.has_unread; + const drawerActionGroups: DrawerAction[][] = [ + canReply ? [ + { + key: "reply", + label: t("Reply"), + icon: { name: "reply" }, + onSelect: () => requestReply("reply") + }, + ...(quickReplyMode === "reply_all" ? [ + { + key: "reply_all", + label: t("Reply all"), + icon: { name: "reply-all" }, + onSelect: () => requestReply("reply_all") + }, + ] : []), + { key: "forward", + label: t("Forward"), + icon: { name: "forward" }, + onSelect: () => requestReply("forward") + }, + ] : [], + [ + ...(canManageLabels && !isTrashed ? [{ + key: "labels", + label: t("Manage labels"), + icon: { icon: TagAdd }, + onSelect: () => labelsWidgetRef.current?.open(), + }] : []), + ...(canStar ? [{ + key: "star", + label: isStarred ? t("Unstar") : t("Star"), + icon: { icon: isStarred ? Star : StarFilled }, + onSelect: () => { + const mutation = isStarred ? markAsUnstarred : markAsStarred; + mutation({ threadIds: [thread.id] }); + }, + }] : []), + { + key: "read", + label: hasUnread ? t("Mark as read") : t("Mark as unread"), + icon: { name: hasUnread ? "mail-open" : "mail-unread" }, + onSelect: () => { + if (hasUnread) { + markAsReadAt({ threadIds: [thread.id], readAt: new Date().toISOString() }); + } else { + // Leave the thread first so the auto-read observer of the open + // view doesn't immediately mark it as read again (same order as + // the desktop action bar). + unselectThread(); + markAsReadAt({ threadIds: [thread.id], readAt: null }); + } + }, + }, + { + key: "copy-link", + label: t("Copy link to thread"), + icon: { icon: Link }, + onSelect: () => copyDeepLink() + }, + ], + [ + ...(removeAction ? [{ key: "remove", ...removeAction }] : []), + ...(canArchive ? [{ + key: "archive", + label: isArchived ? t("Unarchive") : t("Archive"), + icon: isArchived + ? { name: "unarchive", type: IconType.OUTLINED } + : { icon: Archive }, + onSelect: handleArchive, + }] : []), + ...(canReportSpam ? [{ + key: "spam", + label: thread.is_spam ? t("Remove spam report") : t("Report as spam"), + icon: { name: thread.is_spam ? "report_off" : "report", type: IconType.OUTLINED }, + onSelect: () => { + const mutation = thread.is_spam ? markAsNotSpam : markAsSpam; + mutation({ threadIds: [thread.id], onSuccess: unselectThread }); + }, + }] : []), + ], + [ + { key: "accesses", + label: t("Manage accesses"), + icon: { name: "group", type: IconType.OUTLINED }, + onSelect: onOpenAccesses + }, + ...(canLeaveThread ? [{ + key: "leave", + label: t("Leave this thread"), + icon: { name: "exit_to_app", type: IconType.OUTLINED }, + onSelect: leaveThread, + }] : []), + ], + ]; + const drawerActions: DrawerAction[] = drawerActionGroups + .filter((group) => group.length > 0) + .flatMap((group, groupIndex) => group.map((action, actionIndex) => ({ + ...action, + separatorBefore: groupIndex > 0 && actionIndex === 0, + }))); + + return ( + <> + +
+ {removeAction && ( +
+ {canReply && ( + + + ))} +
+ +
+ , + document.body, + )} + + ); +}; + +export default MobileThreadToolbar; diff --git a/src/frontend/src/features/layouts/components/thread-panel/_index.scss b/src/frontend/src/features/layouts/components/thread-panel/_index.scss index 62dfde38..176ab9e3 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-panel/_index.scss @@ -1,3 +1,5 @@ +@use "../../../../styles/utils" as *; + .thread-panel { display: flex; flex-direction: column; @@ -15,6 +17,82 @@ justify-content: space-between; } + // Touch layout: title and count stacked in one column, actions in the + // other. Bottom-aligned so the boxed bar spans both text lines instead + // of pushing the count row down to its own height. Selection mode + // overrides this — see the grid variant below. + .thread-panel__header--columns { + display: flex; + align-items: flex-end; + gap: var(--c--globals--spacings--xs); + } + + // Selection mode. The title here is a running count ("12 conversations + // sélectionnées"), far longer than the folder name it replaces — and + // longest in French. Sharing its row with the action bar left it barely + // half the panel and wrapped it onto two or three lines, deepening the + // header every time the selection crossed a digit. + // + // So the title takes the whole first line, and the two boxed control + // groups face each other underneath — which they can do unaided, unlike + // the count row of the non-selection layout that has no right-hand + // group to balance the bar against. + .thread-panel__header--columns--selection { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + "title title" + "controls actions"; + align-items: center; + row-gap: var(--c--globals--spacings--2xs); + + // The wrapper exists only to stack title over count for the flex + // layout; here its two children are grid items in their own right. + .thread-panel__header--text { + display: contents; + } + + .thread-panel__header--title-row { + grid-area: title; + min-width: 0; + } + + .thread-panel__header--title { + // A full line to itself, so one line is all it needs. Truncate + // rather than wrap if a translation ever overflows: the header + // height must not depend on the selection size. + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + // The nudge that lines the title up with the count below is + // wrong once what sits underneath is the bordered controls box. + transform: none; + } + + .thread-panel__header--details { + grid-area: controls; + } + + .thread-panel__bar { + grid-area: actions; + // margin-left:auto is the flex-row trick for pushing the bar + // right; the grid column already sizes to it. + margin-left: 0; + } + } + + .thread-panel__header--text { + // Owns the leftover width so the count can still ellipsize. + flex: 1 1 auto; + min-width: 0; + } + + // Wrapper capturing the pointer position the context menu opened at: + // it must not add a box of its own around the filter button. + .thread-panel__filter-trigger { + display: inline-flex; + } + .thread-panel__header--title { color: var(--c--contextuals--content--semantic--neutral--primary); font-size: var(--c--globals--font--sizes--h4); @@ -22,6 +100,12 @@ line-height: 1.27; // Adjust title position to align with message count below transform: translateX(-3px); + + @media screen and (max-width: breakpoint("mobile")) { + font-size: var(--c--globals--font--sizes--base); + font-weight: 700; + line-height: 1.375rem; + } } .thread-panel__header--details { @@ -52,6 +136,37 @@ min-width: 0; flex: 1 1 auto; } + + .thread-panel__header--mailbox { + font-weight: 500; + color: var(--c--contextuals--content--semantic--neutral--primary); + } + } + + // Selection mode on touch: select-all and the exit button on the left, + // bulk actions on the right. Nothing else — the count and mailbox name + // would leave no room, and the selection size is the title above. + .thread-panel__header--details--selection { + .c__checkbox { + // Big enough to be a comfortable touch target, unlike the + // mouse-sized default of the row above. + --c--components--forms-checkbox--size: 1.25rem; + // The nudge that lines the checkbox up with the title above + // is wrong once it sits inside a box of its own. + transform: none; + } + } + + // Select-all + exit: the same floating box as the bulk actions facing + // them, so the row reads as two groups rather than loose controls. + .thread-panel__selection-controls { + display: flex; + align-items: center; + flex-shrink: 0; + gap: var(--c--globals--spacings--2xs); + padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--2xs); + + @include control-surface; } } @@ -64,12 +179,66 @@ margin-left: auto; } + // On touch the actions read as one floating control, same box as the + // thread action bar and the native bottom bars. + .thread-panel__bar--floating { + padding: var(--c--globals--spacings--3xs); + gap: var(--c--globals--spacings--3xs); + + @include control-surface; + } + .thread-panel__threads_list { + --thread-list-fade-height: 1.25rem; + + // Fill the remaining height even when the threads don't, so the + // pull-to-refresh touch surface spans the whole panel and the gesture + // works below the last item. min-height:0 keeps overflow scrolling + // working inside the flex column. + flex: 1 1 auto; + min-height: 0; overflow-y: auto; + // Keep the native overscroll bounce from fighting the custom + // pull-to-refresh gesture on iOS/Android web views. + overscroll-behavior-y: contain; padding-inline: var(--c--globals--spacings--xs); display: flex; flex-direction: column; gap: var(--c--globals--spacings--4xs); + + // Soften the hard clipping edge under the header: threads dissolve + // into the panel background instead of being cut off mid-line. The + // gradient mask fades both the surface tint and the blur, so the + // transition has no visible end. Sticky (rather than an overlay on + // the header) keeps it anchored to the scroll viewport, so the + // pull-to-refresh indicator pushing the list down never slides + // underneath it. + &::before { + content: ""; + position: sticky; + top: 0; + z-index: 1; + flex: 0 0 auto; + height: var(--thread-list-fade-height); + // Overlay the first thread instead of taking room in the flow, + // gap included. + margin-bottom: calc(-1 * (var(--thread-list-fade-height) + var(--c--globals--spacings--4xs))); + // Cover the list padding so the fade spans the full panel width. + margin-inline: calc(-1 * var(--c--globals--spacings--xs)); + pointer-events: none; + background-color: var(--c--contextuals--background--surface--secondary); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + mask-image: linear-gradient(to bottom, #000 25%, transparent); + -webkit-mask-image: linear-gradient(to bottom, #000 25%, transparent); + // Only once scrolled: at rest the first thread must stay crisp. + opacity: 0; + transition: opacity 120ms ease-out; + } + + &[data-scrolled="true"]::before { + opacity: 1; + } } &--loading { diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss index 0697e5ed..f96fd9ce 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss @@ -1,24 +1,176 @@ +// Swipe-to-reveal wrapper (touch only). The action panels are laid out +// underneath the row, which slides over them: nothing needs to be measured or +// animated on the panels themselves, they are simply uncovered. +.thread-item-swipe { + position: relative; + border-radius: 4px; + // Clips the action panels, which are wider than the row is tall and would + // otherwise spill over the neighbouring rows. + overflow: hidden; + // MUST stay alongside `overflow: hidden`. The thread list is a scrollable + // flex column, so its children are shrinkable by default; a row survives + // that because its automatic minimum size is its content height — but only + // as long as its overflow is visible. Clipping the box resolves that + // minimum to zero instead, and the whole list collapses to nothing (and + // the infinite scroll then walks through every page, its loader never + // leaving the viewport). + flex-shrink: 0; + // Reserve the vertical axis for the list scroll so the browser never + // claims the horizontal moves — those are ours to interpret. + touch-action: pan-y; +} + +// Neutralised wrapper: off the gesture path it must leave the row exactly as +// it was, both in layout (the row stays the flex item of the list) and in +// painting (no clipping of the focus ring or of raised badges). +.thread-item-swipe--disabled, +.thread-item-swipe--disabled > .thread-item-swipe__content { + display: contents; +} + +.thread-item-swipe__content { + position: relative; + // Opaque, otherwise the panels below stay visible through the row at rest. + background-color: var(--c--contextuals--background--surface--secondary); + transition: transform 0.2s var(--c--globals--transitions--ease-out); + // The gesture writes `transition: none` here while the finger drives the + // row, and hands it back on release (see use-swipe-actions). +} + +// Promoted only for the duration of the gesture: a permanent layer per row +// would keep a compositor texture alive for the whole list. +.thread-item-swipe--swiping > .thread-item-swipe__content { + will-change: transform; +} + +.thread-item-swipe__panel { + position: absolute; + inset-block: 0; + display: flex; + align-items: stretch; +} + +// Both panels span the full width so that pulling further than their buttons +// keeps showing the action colour — the leading one during a commit pull, the +// trailing one during the elastic overshoot — instead of a bare gap. +.thread-item-swipe__panel--start { + inset-inline: 0; + justify-content: flex-start; + background-color: var(--c--contextuals--background--semantic--brand--primary); +} + +.thread-item-swipe__panel--end { + inset-inline: 0; + justify-content: flex-end; + // Matches the outermost button so the overshoot reads as that button + // stretching. + background-color: var(--c--contextuals--background--semantic--error--primary); +} + +// Spanning the full width means the two panels overlap, so the one that is not +// being pulled has to step aside: without this, a long pull to one side +// uncovers the other side's buttons from under the row. `data-side` is written +// by the gesture straight to the DOM, so this costs no re-render. +.thread-item-swipe[data-side="start"] .thread-item-swipe__panel--end, +.thread-item-swipe[data-side="end"] .thread-item-swipe__panel--start, +.thread-item-swipe:not([data-side]) .thread-item-swipe__panel, +.thread-item-swipe[data-side=""] .thread-item-swipe__panel { + visibility: hidden; +} + +.thread-item-swipe__action { + width: 76px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--c--globals--spacings--4xs); + border: none; + cursor: pointer; + padding: 0; + font-size: var(--c--globals--font--sizes--t); + line-height: 1.1; + text-align: center; +} + +.thread-item-swipe__action-label { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-inline: var(--c--globals--spacings--4xs); +} + +.thread-item-swipe__action--read { + background-color: var(--c--contextuals--background--semantic--brand--primary); + color: var(--c--contextuals--content--semantic--brand--on-brand); + // Grows with the pull: past the commit threshold the icon slides along + // with the row, signalling that releasing now runs the action. + transition: transform 0.15s var(--c--globals--transitions--ease-out); +} + +.thread-item-swipe[data-will-commit="true"] .thread-item-swipe__action--read { + transform: scale(1.15); +} + +.thread-item-swipe__action--archive { + background-color: var(--c--contextuals--background--semantic--neutral--primary); + color: var(--c--contextuals--content--semantic--neutral--on-neutral); +} + +.thread-item-swipe__action--trash { + background-color: var(--c--contextuals--background--semantic--error--primary); + color: var(--c--contextuals--content--semantic--error--on-error); +} + +// A row held open is a dismiss surface: dim it so it reads as "tap to close" +// rather than as a normal, tappable row. +.thread-item-swipe--open .thread-item { + opacity: 0.85; +} + +@media (prefers-reduced-motion: reduce) { + .thread-item-swipe__content, + .thread-item-swipe__action--read { + transition: none; + } +} + .thread-item { color: inherit; - text-decoration: none; + // Anchor for the stretched link overlay (.thread-item__link::after). + position: relative; display: flex; flex-direction: row; + align-items: flex-start; justify-content: space-between; - padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--4xs); + padding-block: var(--c--globals--spacings--3xs); + padding-inline: var(--c--globals--spacings--3xs) var(--c--globals--spacings--xs); box-sizing: border-box; border-radius: 4px; border: 1px solid transparent; + // A long press opens selection mode on touch: keep the OS text-selection + // and callout gestures from hijacking it. + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + gap: var(--c--globals--spacings--sm); - & > div:first-child { - padding-top: 0.42rem; - padding-inline: var(--c--globals--spacings--2xs); + & > .thread-item__aside { flex-shrink: 0; display: flex; - flex-direction: column; + flex-direction: row; align-items: center; gap: var(--c--globals--spacings--3xs); } + // The avatar is taller than the checkbox it replaces; pull the column + // back up so the row keeps the same top alignment in both modes. + &:not(:has(.thread-item__checkbox-wrapper)) > .thread-item__aside { + padding-top: var(--c--globals--spacings--4xs); + } + & > div:last-child { flex: 1; display: flex; @@ -28,8 +180,34 @@ } } +// Stretched-link pattern: the link only wraps the thread subject so no +// interactive element (star button) ends up nested inside it; the ::after +// overlay restores the full-row click surface. Elements that must stay +// hoverable/clickable are raised above the overlay with z-index (badges +// column, labels), and the item container's click handler takes over +// navigation for plain clicks landing on them. +.thread-item__link { + color: inherit; + text-decoration: none; + display: block; + flex: 1; + min-width: 0; + + &::after { + content: ''; + position: absolute; + inset: 0; + } + + // The focus ring is drawn on the whole item (see below), not around + // the subject text alone. + &:focus-visible { + outline: none; + } +} + .thread-item:not(.thread-item--active):hover, -.thread-item:not(.thread-item--active):focus { +.thread-item:not(.thread-item--active):has(.thread-item__link:focus) { background-color: var(--c--contextuals--background--semantic--neutral--tertiary); border-color: var(--c--contextuals--border--semantic--neutral--tertiary); } @@ -37,7 +215,7 @@ // Keyboard focus ring, drawn inward so the scroll container does not clip // it. Outline does not conflict with the state backgrounds, so it stays // visible on selected and active rows too. -.thread-item:focus-visible { +.thread-item:has(.thread-item__link:focus-visible) { outline: 2px solid var(--c--contextuals--border--focus); outline-offset: -2px; } @@ -53,7 +231,7 @@ } .thread-item.thread-item--selected:not(.thread-item--active):hover, -.thread-item.thread-item--selected:not(.thread-item--active):focus { +.thread-item.thread-item--selected:not(.thread-item--active):has(.thread-item__link:focus) { background-color: var(--c--contextuals--background--semantic--brand--secondary); border-color: var(--c--contextuals--border--semantic--brand--secondary); } @@ -69,6 +247,8 @@ display: flex; flex-direction: row; justify-content: space-between; + gap: 4px; + align-items: baseline; &.thread-item__row--subject { align-items: center; @@ -104,16 +284,32 @@ flex-shrink: 0; margin: 0; padding: 0; - --c--components--forms-checkbox--size: 1rem; - transform: translate(-3px, -30%); + --c--components--forms-checkbox--size: 1.5rem; & svg { width: var(--c--components--forms-checkbox--size); height: var(--c--components--forms-checkbox--size); } } +.thread-item__avatar { + display: inline-flex; + flex-shrink: 0; + // Decorative and below the stretched link overlay, so a tap on the + // avatar behaves like a tap anywhere else on the row. + pointer-events: none; +} + +.thread-item__identity-slot { + padding: 3px; + min-width: 2.5rem; + min-height: 2.5rem; + box-sizing: border-box; + display: grid; + place-items: center; +} + .thread-item__read-indicator { - --size: 0.5rem; + --size: 0.375rem; flex-shrink: 0; width: var(--size); height: var(--size); @@ -163,7 +359,7 @@ .thread-item__column:has(.thread-item__sender + .thread-item__message-count) { align-items: baseline; - gap: var(--c--globals--spacings--2xs); + gap: var(--c--globals--spacings--3xs); } .thread-item__snippet { @@ -227,6 +423,12 @@ gap: var(--c--globals--spacings--4xs); font-size: var(--c--globals--font--sizes--t); align-items: center; + // Above the stretched link overlay so the star button stays clickable + // and the badge/assignees tooltips keep receiving hover. The cursor + // matches the link surface it visually belongs to. + position: relative; + z-index: 1; + cursor: pointer; } .thread-item__labels { @@ -237,6 +439,12 @@ gap: var(--c--globals--spacings--2xs); min-width: 0; overflow: hidden; + // Above the stretched link overlay so the label name tooltips (title) + // keep receiving hover; clicks still open the thread via the item + // container's click handler. + position: relative; + z-index: 1; + cursor: pointer; } .thread-item__labels .badge { diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx index b2732ce1..bb180a69 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next" -import { Link, useParams } from "@tanstack/react-router" +import { Link, useNavigate, useParams } from "@tanstack/react-router" import { useEffect, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" import clsx from "clsx" @@ -9,8 +9,8 @@ import { ThreadItemSenders } from "./thread-item-senders" import { Badge } from "@/features/ui/components/badge" import { ThreadDragPreview } from "./thread-drag-preview" import { PORTALS } from "@/features/config/constants" -import { Checkbox, Tooltip } from "@gouvfr-lasuite/cunningham-react" -import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit" +import { Button, Checkbox, Tooltip } from "@gouvfr-lasuite/cunningham-react" +import { Icon, IconSize, IconType, UserAvatar } from "@gouvfr-lasuite/ui-kit" import { AssigneesAvatarGroup } from "@/features/ui/components/assignees-avatar-group" import { LabelBadge } from "@/features/ui/components/label-badge" import { useLayoutDragContext } from "@/features/layouts/components/layout-context" @@ -18,6 +18,16 @@ import ViewHelper from "@/features/utils/view-helper" import useCanEditThreads from "@/features/message/use-can-edit-threads" import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature" import { ThreadListboxItemProps } from "../../hooks/use-thread-listbox" +import { AttachFile, Star, StarFilled } from "@gouvfr-lasuite/ui-kit/icons" +import useStarred from "@/features/message/use-starred" +import useThreadUnread from "@/features/message/use-thread-unread" +import { useLongPress } from "@/hooks/use-long-press" + +/** + * Shorter than the hook's default: nothing competes for the press anymore, so + * selection mode can open as soon as the intent is unambiguous. + */ +const TOUCH_SELECTION_DELAY_MS = 350; type ThreadItemProps = { thread: Thread @@ -30,9 +40,13 @@ type ThreadItemProps = { export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, selectedThreadIds, isSelectionMode, tabIndex, itemRef, onFocusItem }: ThreadItemProps) => { const { t, i18n } = useTranslation(); + const navigate = useNavigate(); const params = useParams({ strict: false }) as { mailboxId?: string; threadId?: string } const [isDragging, setIsDragging] = useState(false) const [isExiting, setIsExiting] = useState(false) + // Read from `dragstart`, which can fire before a state update would have + // been committed — hence a ref rather than state. + const isTouchGestureRef = useRef(false) const { setIsDragging: setGlobalDragging, setDragAction } = useLayoutDragContext(); const dragPreviewContainer = useRef(document.getElementById(PORTALS.DRAG_PREVIEW)); const threadDate = useMemo(() => { @@ -56,13 +70,7 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select return thread.messaged_at || thread.draft_messaged_at; }, [thread]) - const hasUnread = useMemo(() => { - const access = thread.accesses.find((a) => a.mailbox.id === params?.mailboxId) - const compareDate = thread.messaged_at; - if (!access || !compareDate) return false - if (!access.read_at) return true - return new Date(compareDate) > new Date(access.read_at) - }, [thread, params?.mailboxId]) + const hasUnread = useThreadUnread(thread); const hasSelection = isSelectionMode || selectedThreadIds.size > 0; const showCheckbox = hasSelection; @@ -79,23 +87,87 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select const snippetEnabled = useFeatureFlag(FEATURE_KEYS.THREAD_SNIPPET); const showSnippet = snippetEnabled && !!thread.snippet; - const handleItemClick = (e: React.MouseEvent) => { + // ``sender_names`` is stored server-side as [first sender, last sender] + // (collapsed to a single entry when both are the same), so the avatar of + // the most recent sender is always the last entry. + const lastSenderName = thread.sender_names?.at(-1); + + // A long press is the touch entry point into selection mode — the only + // one, since dragging is mouse-only. The tap that ends the press still + // fires a click afterwards, which would navigate to the thread we just + // selected — the flag swallows exactly that one click. + const suppressNextClickRef = useRef(false); + const { handlers: longPressHandlers } = useLongPress(() => { + suppressNextClickRef.current = true; + onToggle(thread.id); + }, { delay: TOUCH_SELECTION_DELAY_MS }); + + // Keyboard activation (Enter on the focused option) fires a click with + // detail === 0 and no pointer type: it must navigate even while a + // selection is active, being the only pointer-free way to open a thread. + // Taps report detail === 0 too on some WebKit builds, hence the + // pointerType check — without it a tap on a selected thread would be + // mistaken for a keyboard activation and open the thread. + const isKeyboardActivation = (e: React.MouseEvent) => { + const pointerType = 'pointerType' in e.nativeEvent + ? (e.nativeEvent as PointerEvent).pointerType + : ''; + return e.detail === 0 && !pointerType; + }; + + // Cancelling the navigation has to happen on the way down: the Link + // navigates from its own onClick handler on the , which runs before + // the event bubbles up to this container. It skips navigation when the + // event is already defaultPrevented — and for ctrl/meta/shift clicks, + // which is why modifier-driven selection worked without this, while a + // plain tap in selection mode still opened the thread. + const handleItemClickCapture = (e: React.MouseEvent) => { + if (isKeyboardActivation(e)) return; + if (suppressNextClickRef.current || hasSelection || e.shiftKey || e.ctrlKey || e.metaKey) { + e.preventDefault(); + } + }; + + // Lives on the item container, not the Link: it catches clicks bubbling + // from the stretched ::after overlay as well as clicks on elements raised + // above it (badges column, labels). + const handleItemClick = (e: React.MouseEvent) => { onFocusItem(); - // Keyboard activation (Enter on the focused option) fires a click - // with detail === 0: let it navigate even while a selection is - // active, it is the only pointer-free way to open a thread. - if (e.detail === 0) return; + if (suppressNextClickRef.current) { + suppressNextClickRef.current = false; + e.preventDefault(); + return; + } + if (isKeyboardActivation(e)) return; if (e.shiftKey) { e.preventDefault(); onSelectRange(thread.id); } else if (e.ctrlKey || e.metaKey || hasSelection) { e.preventDefault(); onToggle(thread.id); + } else if (e.target instanceof Element && !e.target.closest('a, button')) { + // Raised elements sit above the stretched link, so plain clicks + // on them never reach it: navigate as the link would have. + navigate({ + to: "/mailbox/$mailboxId/thread/$threadId", + params: { mailboxId: params?.mailboxId ?? '', threadId: thread.id }, + search: true, + }); } // Otherwise, let the Link handle navigation normally }; - const handleDragStart = (e: React.DragEvent) => { + const handleDragStart = (e: React.DragEvent) => { + // Dragging is mouse-only: browsers start their touch drag on the hold + // alone, with no way to make it wait for a movement, so it claimed + // every long press meant to open selection mode. Refusing the + // `dragstart` here rather than dropping `draggable` on the container + // is what actually works — the event bubbles up from the subject + // ``, which anchors make draggable with or without the attribute. + if (isTouchGestureRef.current) { + e.preventDefault(); + return; + } setIsDragging(true) setGlobalDragging(true) @@ -134,6 +206,11 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select setDragAction(null); }; + const { markAsStarred, markAsUnstarred } = useStarred(); + // Starring a trashed or spam thread makes no sense: keep the starred + // state visible (read-only badge) but drop the toggle in those views. + const canToggleStar = !ViewHelper.isTrashedView() && !ViewHelper.isSpamView(); + const dragCount = selectedThreadIds.size > 0 ? selectedThreadIds.size : 1; // Clear any pending drag action if the item unmounts before the @@ -142,12 +219,24 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select useEffect(() => () => setDragAction(null), [setDragAction]); + // The link (the listbox option) only wraps the thread subject, with an + // accessible name built from senders + subject, and stretches its click + // surface over the whole item via a ::after overlay (see SCSS). Nesting + // the whole item inside the link would put the star button — an + // interactive element — inside another interactive element. + // Date, badges and labels sit outside the link, so they feed the + // option's *description* instead: screen readers still announce them + // after the name, while voice-control tools keep targeting the short + // name only. + const sendersId = `thread-item-senders-${thread.id}`; + const subjectId = `thread-item-subject-${thread.id}`; + const dateId = `thread-item-date-${thread.id}`; + const badgesId = `thread-item-badges-${thread.id}`; + const labelsId = `thread-item-labels-${thread.id}`; + return ( <> - { + // A press that never produces a click (finger lifted after + // a scroll, gesture cancelled) would otherwise leave the + // flag armed and swallow the next tap. + suppressNextClickRef.current = false; + isTouchGestureRef.current = true; + longPressHandlers.onTouchStart(e); + }} + onTouchEnd={() => { + isTouchGestureRef.current = false; + longPressHandlers.onTouchEnd(); + }} + // Deliberately leaves the touch flag armed: browsers fire + // `touchcancel` precisely when they claim the gesture for a + // drag, and nothing guarantees it arrives after `dragstart` — + // disarming here could hand them back the press. The next + // `touchstart` rearms it anyway. + onTouchCancel={longPressHandlers.onTouchCancel} > -
- {showCheckbox && ( - // Mouse-only affordance, purely presentational: the option - // itself carries the selection state (aria-selected) and a - // focusable checkbox would add a second tab stop, hence - // aria-hidden + tabIndex=-1. pointer-events:none (see SCSS) - // lets the click fall through to the Link, where - // handleItemClick owns the selection logic; the checkbox - // only reflects `checked`. Driving it from its own click - // handler would fight handleItemClick's preventDefault and - // leave it visually out of sync. - - )} +
+ {/* The checkbox takes over the avatar slot in selection + mode: both are the row's leading affordance, and + stacking them would shift the whole row sideways every + time selection is toggled. */} +
+ {showCheckbox ? ( + // Pointer-only affordance, purely presentational: the option + // itself carries the selection state (aria-selected) and a + // focusable checkbox would add a second tab stop, hence + // aria-hidden + tabIndex=-1. pointer-events:none (see SCSS) + // lets the click fall through to the item container, where + // handleItemClick owns the selection logic; the checkbox + // only reflects `checked`. Driving it from its own click + // handler would fight handleItemClick's preventDefault and + // leave it visually out of sync. + + ) : lastSenderName && ( + // Decorative: the sender is already announced as part of + // the option's accessible name. + + )} +
-
+
{thread.sender_names && thread.sender_names.length > 0 && ( )} @@ -203,7 +319,7 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select }
-
+
{(threadDate || thread.messaged_at) && ( {DateHelper.formatDate((threadDate || thread.messaged_at)!, i18n.resolvedLanguage)} @@ -213,11 +329,23 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select
-

- {thread.subject || t('No subject')} -

+ +

{thread.subject || t('No subject')}

+
-
+
{thread.has_draft && ( - ) : null} - {thread.has_starred && ( - - - )} {thread.has_unread_mention && ( )} + {canToggleStar ? ( + thread.has_starred ? ( +
{showSnippet && ( @@ -337,7 +482,7 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select )}
- +
{(isDragging || isExiting) && dragPreviewContainer.current && createPortal( (null); + const isNative = isNativePlatform(); useEffect(() => { // Follow the cursor and mark the whole document as a valid drop target @@ -28,8 +30,16 @@ export const ThreadDragPreview = ({ count, exiting, onExitEnd }: ThreadDragPrevi const handler = (e: DragEvent) => { e.preventDefault(); if (ref.current) { - ref.current.style.left = `${e.clientX + 7}px`; - ref.current.style.top = `${e.clientY - 7}px`; + if (isNative) { + // Above the finger centered + ref.current.style.left = `${e.clientX - ref.current.offsetWidth / 2}px`; + ref.current.style.top = `${e.clientY - ref.current.offsetHeight * 1.5}px`; + } + else { + // Right of the cursor + ref.current.style.left = `${e.clientX + 7}px`; + ref.current.style.top = `${e.clientY - 7}px`; + } } }; document.addEventListener('dragover', handler, true); diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/thread-item-swipe.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/thread-item-swipe.tsx new file mode 100644 index 00000000..ecd2bd0d --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/thread-item-swipe.tsx @@ -0,0 +1,211 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "@tanstack/react-router"; +import clsx from "clsx"; +import { Icon, IconType } from "@gouvfr-lasuite/ui-kit"; + +import { Thread } from "@/features/api/gen/models"; +import { ThreadRowActions } from "@/features/message/use-thread-row-actions"; +import useThreadUnread from "@/features/message/use-thread-unread"; +import useAbility, { Abilities } from "@/hooks/use-ability"; +import { useSwipeActions } from "@/hooks/use-swipe-actions"; +import ViewHelper from "@/features/utils/view-helper"; + +/** Width (px) of a single revealed action button. Mirrors the SCSS. */ +const ACTION_WIDTH = 76; + +type ThreadItemSwipeProps = { + thread: Thread; + enabled: boolean; + /** Owned by the list, not the row — see `useThreadRowActions`. */ + actions: ThreadRowActions; + children: React.ReactNode; +}; + +/** + * Touch swipe affordances around a thread row. + * + * Swiping right reveals the read/unread toggle, which also fires on its own + * once the row is pulled far enough — a single leading action makes that + * shortcut unambiguous. Swiping left reveals archive and trash as buttons to + * tap: pulling cannot pick between two actions. + * + * The panels only exist while the row is being swiped or held open. At rest a + * list of fifty rows would otherwise carry several hundred nodes it never + * shows, which costs layout and memory on exactly the devices where the + * gesture needs to stay smooth. + * + * The revealed controls are `aria-hidden` and out of the tab order on purpose. + * The gesture is touch-only, and every action it exposes is already reachable + * from the selection toolbar and the thread action bar — exposing them twice + * would only add tab stops to every row of the list. + */ +export const ThreadItemSwipe = ({ + thread, + enabled, + actions, + children, +}: ThreadItemSwipeProps) => { + const { t } = useTranslation(); + const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, thread); + const hasUnread = useThreadUnread(thread); + + // Each of these rebuilds the whole folder tree (translated labels + // included) and reparses the query string, so left uncached they ran a few + // hundred times per render of the list. They only depend on the URL. + const searchStr = useLocation({ select: (location) => location.searchStr }); + const view = useMemo( + () => ({ + isTrashed: ViewHelper.isTrashedView(), + isArchived: ViewHelper.isArchivedView(), + isDrafts: ViewHelper.isDraftsView(), + }), + [searchStr] + ); + + // Same rules as the thread action bar: from the trash the only meaningful + // transition is restoring, and archiving a spam or a draft thread means + // nothing. + const isTrashContext = view.isTrashed || thread.is_trashed; + const isArchivedView = view.isArchived; + const canArchive = + canEditThread && !thread.is_spam && !isTrashContext && !view.isDrafts; + const canTrash = canEditThread; + const endWidth = (canArchive ? ACTION_WIDTH : 0) + (canTrash ? ACTION_WIDTH : 0); + + const toggleRead = () => actions.toggleRead(thread, hasUnread); + + const { containerRef, contentRef, openSide, isSwiping, close } = useSwipeActions({ + startWidth: ACTION_WIDTH, + endWidth, + onCommitStart: toggleRead, + enabled, + }); + + // Closing first keeps the row from sitting open behind a list that the + // mutation is about to reshuffle. + const runAction = (action: () => void) => { + close(); + action(); + }; + + // While a panel is open the row is a dismiss surface: the tap that closes + // it must not also open the thread. Swallowing the click in the capture + // phase is what keeps it from reaching the stretched link below. + const handleClickCapture = (event: React.MouseEvent) => { + if (!openSide) return; + if (event.target instanceof Element && event.target.closest(".thread-item-swipe__action")) { + return; + } + event.preventDefault(); + event.stopPropagation(); + close(); + }; + + // Drafts have no trash stage: in the drafts view the button hard-deletes + // the thread's drafts, mirroring the thread action bar and the selection + // toolbar. + const isDraftDelete = view.isDrafts && !isTrashContext; + + // The button is 76px wide: it gets the resulting state ("Read" / "Unread"), + // not the full sentence, which wrapped onto three lines in French. The + // long form stays as the title. + const readLabel = hasUnread ? t("Read") : t("Unread"); + const readTitle = hasUnread ? t("Mark as read") : t("Mark as unread"); + const archiveLabel = isArchivedView ? t("Unarchive") : t("Archive"); + const trashLabel = isTrashContext ? t("Restore") : t("Delete"); + const trashTitle = isDraftDelete ? t("Delete draft") : trashLabel; + const trashIcon = isDraftDelete + ? "edit_off" + : isTrashContext + ? "restore_from_trash" + : "delete"; + const trashAction = isDraftDelete + ? () => actions.deleteDrafts(thread) + : () => actions.setTrashed(thread, !isTrashContext); + const showPanels = enabled && (isSwiping || openSide !== null); + + // The wrapper is rendered even when the gesture is off (desktop, selection + // mode) and neutralised with `display: contents` instead: dropping it from + // the tree would remount every row of the list each time selection mode is + // entered or left. + return ( +
+ {showPanels && ( + <> + + {endWidth > 0 && ( + + )} + + )} +
+ {children} +
+
+ ); +}; + +export default ThreadItemSwipe; diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.test.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.test.tsx new file mode 100644 index 00000000..e6ba7c6e --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.test.tsx @@ -0,0 +1,184 @@ +import { act, useSyncExternalStore } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let searchStr = ""; +const listeners = new Set<() => void>(); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/features/providers/mailbox", () => ({ + useMailboxContext: () => ({ threads: { results: [{ id: "1" }] } }), +})); + +vi.mock("@/features/native/platform", () => ({ + isNativePlatform: () => false, +})); + +vi.mock("@/hooks/use-url-search-params", async () => { + const { useSyncExternalStore: subscribeToSearch } = await import("react"); + return { + useUrlSearchParams: () => + new URLSearchParams( + subscribeToSearch( + (cb: () => void) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + () => searchStr, + ), + ), + }; +}); + +vi.mock("@/hooks/use-safe-router-push", () => ({ + useSafeRouterPush: () => (params: URLSearchParams) => { + searchStr = params.toString(); + listeners.forEach((cb) => cb()); + }, +})); + +vi.mock("@/features/ui/components/icon", () => ({ + Icon: ({ name }: { name?: string }) => , +})); + +import { ContextMenuProvider } from "@gouvfr-lasuite/ui-kit"; +import { CunninghamProvider } from "@gouvfr-lasuite/cunningham-react"; +import { setSelectedFilters } from "../hooks/use-selected-filters"; +import { DEFAULT_SELECTED_FILTERS } from "../hooks/use-thread-panel-filters"; +import { THREAD_SELECTED_FILTERS_KEY } from "@/features/config/constants"; +import { ThreadPanelFilter } from "./thread-panel-filter"; + +let container: HTMLDivElement; +let root: Root; + +const storedFilters = () => + JSON.parse(localStorage.getItem(THREAD_SELECTED_FILTERS_KEY) ?? "null"); + +const menuItems = () => + Array.from( + document.querySelectorAll("[data-testid^='context-menu-item-']"), + ); + +/** Label of every filter currently ticked in the open menu. */ +const checkedLabels = () => + menuItems() + .filter((el) => el.querySelector("[data-icon='check_box']")) + .map((el) => el.textContent); + +const openMenu = () => { + const trigger = document.querySelector( + "[data-testid='context-menu-trigger']", + )!; + act(() => { + trigger.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 10, + clientY: 10, + }), + ); + }); +}; + +const clickItem = (label: string) => { + const item = menuItems().find((el) => el.textContent?.includes(label))!; + const fire = (type: string) => + item.dispatchEvent( + new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, detail: 1 }), + ); + act(() => { + fire("mousedown"); + fire("mouseup"); + fire("click"); + }); +}; + +/** + * Mirrors the app: the filter is rendered inside a subtree the router may + * rebuild when the search params change, so a toggle can remount it. + */ +const RemountingHost = () => { + const key = useSyncExternalStore( + (cb: () => void) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + () => searchStr, + ); + return ( + + + + + + ); +}; + +describe("ThreadPanelFilter", () => { + beforeEach(() => { + ( + globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); + setSelectedFilters(DEFAULT_SELECTED_FILTERS); + searchStr = "has_unread=1"; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + listeners.clear(); + }); + + it("keeps the previous picks when several filters are toggled in a row", () => { + act(() => { + root.render(); + }); + openMenu(); + + expect(checkedLabels()).toEqual(["Unread"]); + + clickItem("Starred"); + expect(checkedLabels()).toEqual(["Unread", "Starred"]); + expect(storedFilters()).toEqual(["has_unread", "has_starred"]); + + clickItem("Mentioned"); + expect(checkedLabels()).toEqual(["Unread", "Starred", "Mentioned"]); + expect(storedFilters()).toEqual([ + "has_unread", + "has_starred", + "has_mention", + ]); + + clickItem("Assigned to me"); + expect(checkedLabels()).toEqual([ + "Unread", + "Starred", + "Mentioned", + "Assigned to me", + ]); + expect(new URLSearchParams(searchStr).getAll("has_mention")).toEqual(["1"]); + }); + + it("falls back to the default selection when the last filter is unticked", () => { + act(() => { + root.render(); + }); + openMenu(); + + clickItem("Starred"); + clickItem("Unread"); + expect(checkedLabels()).toEqual(["Starred"]); + + clickItem("Starred"); + expect(checkedLabels()).toEqual(["Unread"]); + expect(storedFilters()).toEqual(DEFAULT_SELECTED_FILTERS); + }); +}); diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx index f314b59d..02debdd3 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx @@ -1,45 +1,49 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Button, Tooltip } from "@gouvfr-lasuite/cunningham-react"; -import { ContextMenu, Icon, IconType } from "@gouvfr-lasuite/ui-kit"; -import { THREAD_SELECTED_FILTERS_KEY } from "@/features/config/constants"; +import { ContextMenu, useContextMenuContext } from "@gouvfr-lasuite/ui-kit"; +import type { MenuItem } from "@gouvfr-lasuite/ui-kit"; import { useMailboxContext } from "@/features/providers/mailbox"; +import { isNativePlatform } from "@/features/native/platform"; +import { useLongPress, type LongPressPosition } from "@/hooks/use-long-press"; import { DEFAULT_SELECTED_FILTERS, THREAD_PANEL_FILTER_PARAMS, useThreadPanelFilters, type FilterType, } from "../hooks/use-thread-panel-filters"; +import { + getSelectedFilters, + setSelectedFilters, + useSelectedFilters, +} from "../hooks/use-selected-filters"; +import { Icon } from "@/features/ui/components/icon"; +import { Filter } from "@gouvfr-lasuite/ui-kit/icons"; -const getStoredSelectedFilters = (): FilterType[] => { - try { - const stored = JSON.parse( - localStorage.getItem(THREAD_SELECTED_FILTERS_KEY) ?? "[]", - ); - if (Array.isArray(stored) && stored.length > 0) { - const validFilters = stored.filter( - (value): value is FilterType => - typeof value === "string" && - THREAD_PANEL_FILTER_PARAMS.includes(value as FilterType), - ); - if (validFilters.length > 0) { - return validFilters; - } - } - } catch { - // ignore - } - return DEFAULT_SELECTED_FILTERS; +// Items captured in the menu snapshot hold frozen callbacks, and a remount +// (route change…) would leave them bound to an instance that no longer +// renders. Routing them through a module-level ref keeps every toggle handled +// by the mounted filter, hence against the current URL filters. +const selectFilterRef: { current: (type: FilterType) => void } = { + current: () => {}, }; export const ThreadPanelFilter = () => { const { t } = useTranslation(); - const [selectedFilters, setSelectedFilters] = - useState(getStoredSelectedFilters); + const selectedFilters = useSelectedFilters(); const { threads } = useMailboxContext(); const { hasActiveFilters, activeFilters, applyFilters, clearFilters } = useThreadPanelFilters(); + const { open } = useContextMenuContext(); + const isNative = isNativePlatform(); + // A long press fires a click on release; this guard skips the quick-toggle + // that would otherwise run right after the menu opens. + const longPressFiredRef = useRef(false); + // The menu provider snapshots its items when it opens. Since the menu now + // survives a selection, each toggle has to push a refreshed snapshot back — + // reopening at the very same position, which keeps the popover in place. + const menuPositionRef = useRef({ x: 0, y: 0 }); const isDisabled = !threads?.results.length && !hasActiveFilters; const filterLabels: Record = useMemo( @@ -52,7 +56,34 @@ export const ThreadPanelFilter = () => { [t], ); + const buildMenuItems = (selection: FilterType[]): MenuItem[] => + THREAD_PANEL_FILTER_PARAMS.map((type) => ({ + label: filterLabels[type], + icon: ( + + ), + // Filters are picked several at a time: only an outside click closes + // the menu. + keepOpen: true, + callback: () => selectFilterRef.current(type), + })); + + const filterMenuItems = buildMenuItems(selectedFilters); + + const { handlers: longPressHandlers } = useLongPress((position) => { + longPressFiredRef.current = true; + menuPositionRef.current = position; + open({ position, items: filterMenuItems }); + }); + const handleToggleClick = () => { + // Ignore the synthetic click that follows a long press: the menu just opened. + if (longPressFiredRef.current) { + longPressFiredRef.current = false; + return; + } if (hasActiveFilters) { clearFilters(); } else { @@ -61,15 +92,19 @@ export const ThreadPanelFilter = () => { }; const handleSelectFilter = (type: FilterType) => { - const toggled = selectedFilters.includes(type) - ? selectedFilters.filter((f) => f !== type) - : [...selectedFilters, type]; + // Read the store rather than the render value: several filters are picked + // in a row without the menu closing, so the toggle has to start from the + // selection left by the previous one. + const current = getSelectedFilters(); + const toggled = current.includes(type) + ? current.filter((f) => f !== type) + : [...current, type]; const next = toggled.length > 0 ? toggled : DEFAULT_SELECTED_FILTERS; setSelectedFilters(next); - localStorage.setItem(THREAD_SELECTED_FILTERS_KEY, JSON.stringify(next)); if (hasActiveFilters) { applyFilters(next); } + open({ position: menuPositionRef.current, items: buildMenuItems(next) }); }; const getTooltipContent = () => { @@ -86,33 +121,50 @@ export const ThreadPanelFilter = () => { }); }; - return ( - ({ - label: filterLabels[type], - icon: ( - - ), - callback: () => handleSelectFilter(type), - }))} - > + useEffect(() => { + selectFilterRef.current = handleSelectFilter; + }); + + const trigger = ( +
+ )} + {canReportSpam && ( + +
+ ); + + const headerText = ( + <> +
+ {showSelectionBar ? ( +

+ {t('{{count}} selected threads', { count: selectedThreadIds.size })} +

+ ) : ( + <> +

{title}

+ {/* On the native app the filter lives in the bottom bar. */} + {!isNativePlatform() && } + + )}
+
+ {showSelectionBar ? ( + // Select-all and exit sit in a box of their own, mirroring + // the bulk-action bar opposite. The count and mailbox name + // step aside: on a narrow screen the row has to fit both + // boxes, and the selection size is already the title above. +
+ {selectAllCheckbox} +
+ ) : ( + <> + {selectAllCheckbox} +

+ {showMailboxName && ( + <> + {mailboxName} + + + )} + {detailsLabel} +

+ + )} + {/* Off touch the actions stay inline with the count. */} + {!hasFloatingActions && actionsBar} +
+ + ); + + return ( +
+ {hasFloatingActions ? ( + // The box around the actions would deepen the count row; + // stacking the title and count in their own column instead + // lets the bar sit alongside both, keeping the header short. + // Selection mode reflows this into a grid (see the SCSS): its + // title is a running count too long to share a row with the bar. +
+
{headerText}
+ {actionsBar} +
+ ) : headerText}
) } diff --git a/src/frontend/src/features/layouts/components/thread-panel/hooks/use-selected-filters.ts b/src/frontend/src/features/layouts/components/thread-panel/hooks/use-selected-filters.ts new file mode 100644 index 00000000..4e36da5b --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-panel/hooks/use-selected-filters.ts @@ -0,0 +1,65 @@ +import { useSyncExternalStore } from "react"; +import { THREAD_SELECTED_FILTERS_KEY } from "@/features/config/constants"; +import { + DEFAULT_SELECTED_FILTERS, + THREAD_PANEL_FILTER_PARAMS, + type FilterType, +} from "./use-thread-panel-filters"; + +/** + * Which filters the quick-filter button applies, kept outside React on + * purpose. The context menu freezes the items it was opened with, so a toggle + * runs from a callback that may outlive the component that created it: reading + * and writing the selection here makes every toggle start from the current + * value instead of the one captured when the menu opened. + */ +const listeners = new Set<() => void>(); +let selectedFilters: FilterType[] | null = null; + +const readStoredFilters = (): FilterType[] => { + try { + const stored = JSON.parse( + localStorage.getItem(THREAD_SELECTED_FILTERS_KEY) ?? "[]", + ); + if (Array.isArray(stored)) { + const validFilters = stored.filter( + (value): value is FilterType => + typeof value === "string" && + THREAD_PANEL_FILTER_PARAMS.includes(value as FilterType), + ); + if (validFilters.length > 0) { + return validFilters; + } + } + } catch { + // ignore + } + return DEFAULT_SELECTED_FILTERS; +}; + +/** + * The current selection. The value is cached so `useSyncExternalStore` gets a + * stable reference between renders. + */ +export const getSelectedFilters = (): FilterType[] => + (selectedFilters ??= readStoredFilters()); + +export const setSelectedFilters = (filters: FilterType[]) => { + selectedFilters = filters; + try { + localStorage.setItem(THREAD_SELECTED_FILTERS_KEY, JSON.stringify(filters)); + } catch { + // ignore + } + listeners.forEach((listener) => listener()); +}; + +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const useSelectedFilters = (): FilterType[] => + useSyncExternalStore(subscribe, getSelectedFilters, getSelectedFilters); diff --git a/src/frontend/src/features/layouts/components/thread-panel/index.tsx b/src/frontend/src/features/layouts/components/thread-panel/index.tsx index f48104dd..43e6cc1b 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/index.tsx @@ -1,19 +1,28 @@ import { useMailboxContext } from "@/features/providers/mailbox"; import { SKIP_LINK_TARGET_ID } from "@/features/ui/components/skip-link"; import { ThreadItem } from "./components/thread-item"; +import { ThreadItemSwipe } from "./components/thread-item/thread-item-swipe"; import { Spinner } from "@gouvfr-lasuite/ui-kit"; import { useTranslation } from "react-i18next"; import { Button } from "@gouvfr-lasuite/cunningham-react"; -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef, useCallback, type UIEvent } from "react"; import { useUrlSearchParams } from "@/hooks/use-url-search-params"; import ThreadPanelHeader from "./components/thread-panel-header"; import { useThreadSelection } from "@/features/providers/thread-selection"; import { useScrollRestore } from "@/features/providers/scroll-restore"; import { useThreadPanelFilters } from "./hooks/use-thread-panel-filters"; import { useThreadListbox } from "./hooks/use-thread-listbox"; +import { isNativePlatform } from "@/features/native/platform"; +import { usePullToRefresh } from "@/features/native/use-pull-to-refresh"; +import { PullToRefreshIndicator } from "@/features/native/pull-to-refresh-indicator"; +import { useRefreshFeedback } from "@/hooks/use-refresh-feedback"; +import { closeSwipedRows } from "@/hooks/use-swipe-actions"; +import { useThreadRowActions } from "@/features/message/use-thread-row-actions"; + +const PULL_TO_REFRESH_THRESHOLD = 70; export const ThreadPanel = () => { - const { threads, queryStates, unselectThread, loadNextThreads, selectedThread, selectedMailbox } = useMailboxContext(); + const { threads, queryStates, unselectThread, loadNextThreads, selectedThread, selectedMailbox, invalidateMailbox } = useMailboxContext(); const searchParams = useUrlSearchParams(); const isSearch = searchParams.has('search'); const { hasActiveFilters, clearFilters } = useThreadPanelFilters(); @@ -30,6 +39,7 @@ export const ThreadPanel = () => { toggleThread, selectRange, selectAllThreads, + deselectAllThreads, clearSelection, enableSelectionMode, isAllSelected, @@ -40,6 +50,34 @@ export const ThreadPanel = () => { const { getItemProps, onKeyDown: handleListboxKeyDown, onBlur: handleListboxBlur } = useThreadListbox(threads?.results); + const isNative = isNativePlatform(); + // Mounted once for the whole list — see `useThreadRowActions`. + const rowActions = useThreadRowActions(); + const { isRefreshing: isMailboxRefreshing, feedback: refreshFeedback, clearFeedback, refresh } = useRefreshFeedback(); + const { containerRef: pullToRefreshRef, indicatorRef } = usePullToRefresh({ + onRefresh: () => refresh(invalidateMailbox), + enabled: isNative, + threshold: PULL_TO_REFRESH_THRESHOLD, + }); + + // Single node feeding both the scroll-restore ref object and the + // pull-to-refresh callback ref. + const setThreadsListNode = useCallback((node: HTMLDivElement | null) => { + scrollContainerRef.current = node; + pullToRefreshRef(node); + }, [scrollContainerRef, pullToRefreshRef]); + + // Drive the top fade from the node itself: putting "is scrolled" in state + // would re-render every thread of the list on each scroll crossing. + const handleThreadsListScroll = useCallback((event: UIEvent) => { + const list = event.currentTarget; + list.dataset.scrolled = String(list.scrollTop > 0); + // A row left open by a swipe would otherwise scroll away still open, + // and come back holding actions the user has moved on from. + closeSwipedRows(); + handleScroll(); + }, [handleScroll]); + const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => { const target = entries[0]; if (target.isIntersecting && threads?.next && !queryStates.threads.isFetchingNextPage) { @@ -104,9 +142,13 @@ export const ThreadPanel = () => { selectionReadStatus={selectionReadStatus} selectionStarredStatus={selectionStarredStatus} onSelectAll={selectAllThreads} + onDeselectAll={deselectAllThreads} onClearSelection={clearSelection} onEnableSelectionMode={enableSelectionMode} onDisableSelectionMode={clearSelection} + isRefreshing={isMailboxRefreshing} + refreshFeedback={refreshFeedback} + onClearRefreshFeedback={clearFeedback} /> {isEmpty ? (
@@ -118,27 +160,38 @@ export const ThreadPanel = () => {
) : ( -
+ <> + {isNative && } +
{threads?.results.map((thread) => ( - + enabled={isNative && !isSelectionMode && selectedThreadIds.size === 0} + actions={rowActions} + > + + ))} {threads!.next && (
@@ -150,7 +203,8 @@ export const ThreadPanel = () => { )}
)} -
+
+ )}
); diff --git a/src/frontend/src/features/layouts/components/thread-view/_index.scss b/src/frontend/src/features/layouts/components/thread-view/_index.scss index 693462de..88dd28ed 100644 --- a/src/frontend/src/features/layouts/components/thread-view/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/_index.scss @@ -43,16 +43,8 @@ } @media screen and (max-width: breakpoint(mobile)) { - .thread-view { - position: fixed; - top: var(--header-height); - left: 0; - right: 0; - bottom: 0; - z-index: 900; - height: auto; - } - + // The thread view fills its panel in normal flow (the thread route renders + // it full-width on mobile), so no fixed overlay / header offset is needed. .thread-view--talk .thread-message:not(.thread-message--sender), .thread-view--talk .thread-message.thread-message--sender { margin-inline: 0; @@ -66,14 +58,33 @@ height: 100%; } +// Single row: labels never steal a second line from the reading area, the +// overflow is swiped through instead. Snapping stops a swipe on a label +// boundary rather than mid-badge. .thread-view__labels-list { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; gap: 0.5rem; - padding: var(--c--globals--spacings--base); - padding-left: var(--c--globals--spacings--md); - padding-top: 0; + padding-top: var(--c--globals--spacings--xs); + // Breathing room for focus rings, which the scroll container clips. + padding-bottom: 2px; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + // The row is swiped/dragged, not scrolled with a visible bar: the bar + // would eat back the vertical space this row is meant to save. + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + & > * { + flex: 0 0 auto; + scroll-snap-align: start; + } } @@ -98,39 +109,10 @@ } +// Scrolls away with the content: only the action bar above stays pinned. .thread-view__header { padding-inline: var(--c--globals--spacings--md) var(--c--globals--spacings--base); - padding-block: var(--c--globals--spacings--b) var(--c--globals--spacings--base); - background: linear-gradient(to top, var(--c--globals--colors--black-000), var(--c--contextuals--background--surface--tertiary) 15px, var(--c--contextuals--background--surface--tertiary) 100%); - z-index: 10; - container-type: inline-size; -} - -.thread-view__header__top { - display: flow-root; - - .thread-action-bar__container { - float: right; - width: auto; - margin-left: var(--c--globals--spacings--base); - margin-bottom: var(--c--globals--spacings--4xs); - } - - // Switch to a stacked layout before the title is squeezed down to a few - // orphan letters on its first line. Using a container query (not a media - // query) keeps the trigger in sync with the resizable thread panel width. - @container (max-width: 35rem) { - display: flex; - flex-direction: column; - gap: var(--c--globals--spacings--xs); - - .thread-action-bar__container { - float: none; - align-self: flex-end; - margin-left: 0; - margin-bottom: 0; - } - } + padding-bottom: var(--c--globals--spacings--base); } .thread-view__subject { @@ -138,17 +120,90 @@ font-size: var(--c--globals--font--sizes--h4); vertical-align: baseline; - & > .thread-view__subject__star { - font-size: var(--c--globals--font--sizes--h4); - color: var(--c--contextuals--background--palette--yellow--secondary); - transform: translate(-4px, 3px) + & > .thread-view__subject__star-button { + transform: translate(-4px, 3px); } } +// Only the action bar is pinned while reading. The row itself is transparent +// and lets pointer events through, so the content scrolling underneath stays +// readable and clickable around the floating controls. .thread-view__sticky-container { position: sticky; top: 0; z-index: 2; + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--c--globals--spacings--xs); + padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--base); + pointer-events: none; + // Sizes the container query driving where the compact subject lands + // (beside or below the bar), in sync with the resizable panel width. + container-type: inline-size; + + .thread-action-bar__container { + pointer-events: auto; + } +} + +// One-line recall of the subject, revealed once the real header has scrolled +// away. Same floating-control surface as the action bar so it stays readable +// over the content passing underneath. +.thread-view__sticky-subject { + @include control-surface; + + display: flex; + align-items: center; + align-self: stretch; + gap: var(--c--globals--spacings--3xs); + // Anchors the recall to the left edge while the bar keeps hugging the right. + margin-right: auto; + min-width: 0; + padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--2xs); + font-weight: 700; + pointer-events: auto; + animation: thread-view-sticky-subject-in 150ms ease; +} + +.thread-view__sticky-subject__text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +// Not enough room beside the action bar: the recall drops to its own line +// below, still overlaying the content rather than pushing it down (keeping +// the sticky row height stable is what spares us any collapse feedback loop). +@container (max-width: 35rem) { + .thread-view__sticky-subject { + position: absolute; + top: 100%; + left: var(--c--globals--spacings--base); + right: var(--c--globals--spacings--base); + margin-right: 0; + } +} + +// On mobile the recall lands on its own overlay line (see container query +// above) and eats scarce reading space: drop it entirely, only the action +// bar stays pinned. Desktop keeps the recall. +@media screen and (max-width: breakpoint(mobile)) { + .thread-view__sticky-subject { + display: none; + } +} + +@keyframes thread-view-sticky-subject-in { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } } // Magnetic-focus highlight applied after deep-link navigation lands on a @@ -216,6 +271,9 @@ } @media (prefers-reduced-motion: reduce) { + .thread-view__sticky-subject { + animation: none; + } .thread-message.thread-view__highlight, .thread-event.thread-view__highlight .thread-event__content { animation: none; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/message-reply-form/draft-actions-menu.tsx b/src/frontend/src/features/layouts/components/thread-view/components/message-reply-form/draft-actions-menu.tsx index d50ee604..f83c6fd7 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/message-reply-form/draft-actions-menu.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/message-reply-form/draft-actions-menu.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Tooltip } from "@gouvfr-lasuite/cunningham-react"; -import { DropdownMenu, Icon, IconType } from "@gouvfr-lasuite/ui-kit"; +import { DropdownMenu } from "@gouvfr-lasuite/ui-kit"; import { Message } from "@/features/api/gen"; import useCopyDeepLink from "@/features/message/use-copy-deep-link"; +import { Icon } from "@/features/ui/components/icon"; +import { Link, MoreVertical, Trash } from "@gouvfr-lasuite/ui-kit/icons"; type DraftActionsMenuProps = { message: Message; @@ -24,12 +26,12 @@ const DraftActionsMenu = ({ message, onDelete }: DraftActionsMenuProps) => { const options = [ { label: t('Copy link to message'), - icon: , + icon: , callback: () => copyDeepLink({ messageId: message.id }), }, ...(onDelete ? [{ label: t('Delete draft'), - icon: , + icon: , callback: onDelete, showSeparator: true, }] : []), @@ -40,7 +42,7 @@ const DraftActionsMenu = ({ message, onDelete }: DraftActionsMenuProps) => { - + {!hideTrigger && ( + + + + )} modalTitle={isAssignmentContext ? t('Share and assign the thread') : t('Share the thread')} isOpen={isShareModalOpen} diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/_index.scss index e9002351..b16ebaf9 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/_index.scss @@ -1,3 +1,5 @@ +@use "../../../../../../styles/utils" as *; + .thread-action-bar__container { display: flex; flex-direction: row; @@ -9,13 +11,14 @@ display: flex; flex-direction: row; align-items: center; - background: var(--c--contextuals--background--surface--primary); width: inherit; - border-radius: 4px; - box-shadow: 0 2px 5px 0 var(--c--contextuals--background--semantic--contextual--primary-hover); padding: var(--c--globals--spacings--3xs); gap: var(--c--globals--spacings--2xs); + // Same box as the other floating controls (mobile header, bottom bars): + // the bar hovers over the thread, so it reads as one surface. + @include control-surface; + & > .c__separator--vertical.with-padding { margin-inline: var(--c--globals--spacings--3xs); } diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/index.tsx index 5b11b695..8e2dc48d 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/index.tsx @@ -2,8 +2,8 @@ import { useMailboxContext } from "@/features/providers/mailbox"; import useRead from "@/features/message/use-read"; import useTrash from "@/features/message/use-trash"; import useAbility, { Abilities } from "@/hooks/use-ability"; -import { DropdownMenu, Icon, IconType, VerticalSeparator } from "@gouvfr-lasuite/ui-kit" -import { Button, Tooltip, useModals } from "@gouvfr-lasuite/cunningham-react" +import { DropdownMenu, IconType, VerticalSeparator } from "@gouvfr-lasuite/ui-kit" +import { Button, Tooltip } from "@gouvfr-lasuite/cunningham-react" import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ThreadAccessesWidget, type ThreadAccessesWidgetHandle } from "../thread-accesses-widget"; @@ -11,13 +11,12 @@ import { AssigneesWidget } from "../assignees-widget"; import { LabelsWidget } from "@/features/layouts/components/labels-widget"; import useArchive from "@/features/message/use-archive"; import useSpam from "@/features/message/use-spam"; -import useStarred from "@/features/message/use-starred"; import useDeleteDrafts from "@/features/message/use-delete-drafts"; -import useDeleteThreadAccess from "@/features/message/use-delete-thread-access"; +import useLeaveThread from "@/features/message/use-leave-thread"; import ViewHelper from "@/features/utils/view-helper"; -import { MailboxRoleChoices, ThreadAccessRoleChoices } from "@/features/api/gen"; -import { addToast, ToasterItem } from "@/features/ui/components/toaster"; import useCopyDeepLink from "@/features/message/use-copy-deep-link"; +import { Icon } from "@/features/ui/components/icon"; +import { Archive, Link, MoreVertical, Trash } from "@gouvfr-lasuite/ui-kit/icons"; type ThreadActionBarProps = { canUndelete: boolean; @@ -26,16 +25,14 @@ type ThreadActionBarProps = { export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarProps) => { const { t } = useTranslation(); - const { selectedMailbox, selectedThread, unselectThread, messages } = useMailboxContext(); + const { selectedThread, unselectThread, messages } = useMailboxContext(); const { markAsReadAt } = useRead(); const { markAsTrashed, markAsUntrashed } = useTrash(); const { markAsArchived, markAsUnarchived } = useArchive(); const { markAsSpam, markAsNotSpam } = useSpam(); - const { markAsStarred, markAsUnstarred } = useStarred(); const { deleteDrafts } = useDeleteDrafts(); - const { deleteThreadAccess } = useDeleteThreadAccess(); + const { canLeaveThread, leaveThread } = useLeaveThread(); const isDraftsView = ViewHelper.isDraftsView(); - const modals = useModals(); const accessesWidgetRef = useRef(null); // Full edit rights on the thread — gates archive, spam, delete. // Star and read/unread toggle remain visible because they are personal @@ -43,7 +40,12 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr // Label assignment is scoped to the mailbox (see `LabelsWidget`) and // therefore stays visible for viewer-only threads. const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread ?? null); - const canShowArchiveCTA = canEditThread && !selectedThread?.is_spam + // Archiving, reporting as spam or labelling makes no sense from the + // trash view (or on a fully trashed thread opened elsewhere): the only + // relevant transition from the trash is restoring it (Undelete). + const isTrashContext = ViewHelper.isTrashedView() || canUndelete; + const canShowArchiveCTA = canEditThread && !selectedThread?.is_spam && !isTrashContext + const canShowSpamCTA = canEditThread && !isTrashContext // A thread holding nothing but the draft being composed: archiving, spam // reporting or trashing it is meaningless — only permanent deletion is. When // the draft replies inside a real thread, those actions still make sense but @@ -51,49 +53,38 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr const isDraftOnlyThread = isDraftsView && !!messages && messages.length > 0 && messages.every((m) => m.is_draft); const showDraftThreadManagement = isDraftsView && !isDraftOnlyThread && canEditThread; const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const isStarred = selectedThread?.has_starred; const hasUnread = selectedThread?.has_unread; - const mailboxAccess = selectedThread?.accesses.find((a) => a.mailbox.id === selectedMailbox?.id); - const hasOnlyOneEditor = selectedThread?.accesses.filter((a) => a.role === ThreadAccessRoleChoices.editor).length === 1; - const canLeaveThread = selectedMailbox?.role !== MailboxRoleChoices.viewer && mailboxAccess && selectedThread && (!hasOnlyOneEditor || mailboxAccess.role !== ThreadAccessRoleChoices.editor); const copyDeepLink = useCopyDeepLink(); - const handleLeaveThread = async () => { - if (!mailboxAccess || !selectedThread) return; - const decision = await modals.deleteConfirmationModal({ - title: t('Leave this thread?'), - children: t( - 'You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.', - { mailboxName: mailboxAccess.mailbox.email } - ), - }); - if (decision !== 'delete') return; - deleteThreadAccess({ - accessId: mailboxAccess.id, - accessMailboxId: mailboxAccess.mailbox.id, - threadId: selectedThread.id, - onSuccess: () => { - addToast(

{t('You left the thread')}

); - }, - }); - }; - return (
accessesWidgetRef.current?.open()} />
- -
+
+
{children}
+
+ + ); +}; diff --git a/src/frontend/src/features/ui/components/icon/_index.scss b/src/frontend/src/features/ui/components/icon/_index.scss new file mode 100644 index 00000000..010df7a2 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/_index.scss @@ -0,0 +1,20 @@ +.messages-icon { + display: inline-flex; + flex-shrink: 0; + // em-based so any font-size context (own default below, explicit inline + // size, or a container like Cunningham's .c__button__icon) drives the box. + width: 1em; + height: 1em; + + svg { + display: block; + width: 100%; + height: 100%; + } +} + +// Default size only — :where() keeps specificity at zero so container rules +// (e.g. .c__button--small .c__button__icon *) win without effort. +:where(.messages-icon, .messages-icon-svg) { + font-size: 24px; +} diff --git a/src/frontend/src/features/ui/components/icon/assets/assign.svg b/src/frontend/src/features/ui/components/icon/assets/assign.svg new file mode 100644 index 00000000..5ebaa9e5 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/assign.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/at-sign.svg b/src/frontend/src/features/ui/components/icon/assets/at-sign.svg new file mode 100644 index 00000000..d6932fac --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/at-sign.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/circle-dashed.svg b/src/frontend/src/features/ui/components/icon/assets/circle-dashed.svg new file mode 100644 index 00000000..a861c7ae --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/circle-dashed.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/dns.svg b/src/frontend/src/features/ui/components/icon/assets/dns.svg new file mode 100644 index 00000000..61eb2f61 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/dns.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/filter-notification.svg b/src/frontend/src/features/ui/components/icon/assets/filter-notification.svg new file mode 100644 index 00000000..593abc9f --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/filter-notification.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/fold.svg b/src/frontend/src/features/ui/components/icon/assets/fold.svg new file mode 100644 index 00000000..e98ca090 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/fold.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/forward.svg b/src/frontend/src/features/ui/components/icon/assets/forward.svg new file mode 100644 index 00000000..f15fe0ec --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/forward.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/inbox.svg b/src/frontend/src/features/ui/components/icon/assets/inbox.svg new file mode 100644 index 00000000..fcd5b91e --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/inbox.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/mail-open.svg b/src/frontend/src/features/ui/components/icon/assets/mail-open.svg new file mode 100644 index 00000000..daa9f4fd --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/mail-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/mail-out.svg b/src/frontend/src/features/ui/components/icon/assets/mail-out.svg new file mode 100644 index 00000000..edda6b7d --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/mail-out.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/mail-plus.svg b/src/frontend/src/features/ui/components/icon/assets/mail-plus.svg new file mode 100644 index 00000000..875ae95a --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/mail-plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/mail-unread.svg b/src/frontend/src/features/ui/components/icon/assets/mail-unread.svg new file mode 100644 index 00000000..178bb606 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/mail-unread.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/reply-all.svg b/src/frontend/src/features/ui/components/icon/assets/reply-all.svg new file mode 100644 index 00000000..1c257421 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/reply-all.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/reply.svg b/src/frontend/src/features/ui/components/icon/assets/reply.svg new file mode 100644 index 00000000..3f0b46c0 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/reply.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/sign.svg b/src/frontend/src/features/ui/components/icon/assets/sign.svg new file mode 100644 index 00000000..50296ee1 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/sign.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/signature.svg b/src/frontend/src/features/ui/components/icon/assets/signature.svg new file mode 100644 index 00000000..07b2e1f6 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/signature.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/tag-fill.svg b/src/frontend/src/features/ui/components/icon/assets/tag-fill.svg new file mode 100644 index 00000000..1e771623 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/tag-fill.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/tag.svg b/src/frontend/src/features/ui/components/icon/assets/tag.svg new file mode 100644 index 00000000..e8247ccf --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/tag.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/text-wrap.svg b/src/frontend/src/features/ui/components/icon/assets/text-wrap.svg new file mode 100644 index 00000000..3400b000 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/text-wrap.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/assets/unfold.svg b/src/frontend/src/features/ui/components/icon/assets/unfold.svg new file mode 100644 index 00000000..5156f296 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/assets/unfold.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/src/features/ui/components/icon/index.test.tsx b/src/frontend/src/features/ui/components/icon/index.test.tsx new file mode 100644 index 00000000..d9d0871c --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/index.test.tsx @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from "vitest"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { IconSvgProps } from "@gouvfr-lasuite/ui-kit"; + +import { APP_ICON_NAMES, Icon } from "./index"; + +type MockMaterialIconProps = { + name: string; + role?: string; + "aria-label"?: string; + "aria-hidden"?: boolean; +}; + +vi.mock("@gouvfr-lasuite/ui-kit", () => ({ + Icon: (props: MockMaterialIconProps) => ( + + ), + IconSize: { + X_SMALL: "xsmall", + SMALL: "small", + MEDIUM: "medium", + LARGE: "large", + X_LARGE: "xlarge", + }, + IconType: { OUTLINED: "outlined", FILLED: "filled" }, + getIconSize: () => 24, +})); + +// Eager + raw so the assets themselves can be asserted on, without touching +// the lazy loading used by the component. +const rawAssets = import.meta.glob("./assets/*.svg", { + query: "?raw", + import: "default", + eager: true, +}); + +const assetNames = Object.keys(rawAssets) + .map((path) => path.replace("./assets/", "").replace(".svg", "")) + .sort(); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const renderInDom = async (element: React.JSX.Element) => { + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render(element); + }); + // The lazy SVG import resolves through real module I/O, so give it a few + // macrotasks rather than a single microtask flush. + for (let i = 0; i < 20 && !container.querySelector("svg"); i += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + return container; +}; + +describe("Icon", () => { + it("keeps APP_ICON_NAMES in sync with the SVG assets", () => { + expect(assetNames).toEqual([...APP_ICON_NAMES].sort()); + }); + + it("only uses customizable colors in the SVG assets", () => { + // The only fixed color allowed is tag-fill's decorative light sheen. + for (const [path, markup] of Object.entries(rawAssets)) { + const fixedColors = ( + markup.match(/(?:fill|stroke)="(?!currentColor|none)[^"]*"/g) ?? + [] + ).filter((attr) => !attr.includes("#F6F8FA")); + expect(fixedColors, path).toEqual([]); + } + }); + + it("renders an app icon as inline SVG once loaded", async () => { + const container = await renderInDom( + , + ); + const wrapper = container.querySelector(".messages-icon"); + expect(wrapper?.getAttribute("role")).toBe("img"); + expect(wrapper?.getAttribute("style")).toContain("font-size: 16px"); + expect(wrapper?.querySelector("svg")).toBeTruthy(); + }); + + it("sets no inline size without a size prop, so a container can drive it", async () => { + const container = await renderInDom(); + const wrapper = container.querySelector(".messages-icon"); + expect(wrapper?.getAttribute("style") ?? "").not.toContain( + "font-size", + ); + }); + + it("is hidden from assistive tech when it has no label", async () => { + const container = await renderInDom(); + const wrapper = container.querySelector(".messages-icon"); + expect(wrapper?.getAttribute("aria-hidden")).toBe("true"); + expect(wrapper?.getAttribute("role")).toBeNull(); + }); + + it("falls back to the ui-kit Material icon for unknown names", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('data-material-icon="close"'); + expect(html).toContain('aria-hidden="true"'); + expect(html).not.toContain("messages-icon"); + }); + + it("labels the Material fallback when an aria-label is given", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('role="img"'); + expect(html).toContain('aria-label="Fermer"'); + expect(html).not.toContain("aria-hidden"); + }); + + it("proxies ui-kit SVG icon components through the icon prop", () => { + const UIKitSvgIcon = (props: IconSvgProps) => ( + + ); + + const decorative = renderToStaticMarkup( + , + ); + expect(decorative).toContain('data-size="32"'); + expect(decorative).toContain('data-color="red"'); + expect(decorative).toContain('aria-hidden="true"'); + expect(decorative).not.toContain('width="1em"'); + + const labelled = renderToStaticMarkup( + , + ); + expect(labelled).toContain('role="img"'); + expect(labelled).toContain('aria-label="Déplier"'); + expect(labelled).not.toContain("aria-hidden"); + }); + + it("makes unsized ui-kit SVG icons follow the font-size context", () => { + const UIKitSvgIcon = (props: IconSvgProps) => ( + + ); + const html = renderToStaticMarkup(); + expect(html).toContain('class="messages-icon--svg"'); + expect(html).toContain('width="1em"'); + expect(html).toContain('height="1em"'); + }); +}); diff --git a/src/frontend/src/features/ui/components/icon/index.tsx b/src/frontend/src/features/ui/components/icon/index.tsx new file mode 100644 index 00000000..23fbeb37 --- /dev/null +++ b/src/frontend/src/features/ui/components/icon/index.tsx @@ -0,0 +1,198 @@ +import { + Icon as MaterialIcon, + IconSize, + IconSvgProps, + IconType, + getIconSize, +} from "@gouvfr-lasuite/ui-kit"; +import clsx from "clsx"; +import { useEffect, useReducer } from "react"; + +/** + * Lazy loaders for the app-specific SVG icons. Non-eager on purpose: each + * icon becomes its own chunk, fetched the first time it is rendered, so the + * main bundle never embeds icons the app does not consume. + */ +const APP_ICON_MODULES = import.meta.glob("./assets/*.svg", { + query: "?raw", + import: "default", +}); + +export const APP_ICON_NAMES = [ + "assign", + "at-sign", + "circle-dashed", + "dns", + "filter-notification", + "fold", + "forward", + "inbox", + "mail-open", + "mail-out", + "mail-plus", + "mail-unread", + "reply-all", + "reply", + "sign", + "signature", + "tag-fill", + "tag", + "text-wrap", + "unfold", +] as const; + +export type AppIconName = (typeof APP_ICON_NAMES)[number]; + +const isAppIconName = (name: string): name is AppIconName => + `./assets/${name}.svg` in APP_ICON_MODULES; + +type IconCommonProps = { + /** + * Omit it inside a sized container (ui-kit Button icon slot…): with no + * explicit size the icon is em-based and lets the container's font-size + * rules drive its dimensions. An explicit size always wins. + */ + size?: IconSize | number; + color?: string; + className?: string; + "aria-label"?: string; +}; + +export type IconProps = IconCommonProps & + ( + | { + /** + * An app icon name (rendered from our own SVG set), or any + * Material icon name as fallback — app icons win on collision. + */ + name: AppIconName | (string & {}); + /** Only used by the Material fallback. */ + type?: IconType; + icon?: never; + } + | { + /** + * An SVG icon component imported from + * `@gouvfr-lasuite/ui-kit/icons`. Passed as a component (not a + * name) so only the icons actually imported end up in the bundle. + */ + icon: (props: IconSvgProps) => React.JSX.Element; + name?: never; + type?: never; + } + ); + +/** + * ui-kit icons ship without any ARIA attribute — a Material icon even gets + * its ligature ("close", "search"…) read aloud. Whatever the icon source, + * enforce the same policy: decorative by default, labelled image otherwise. + */ +const a11yProps = (ariaLabel?: string) => + ariaLabel + ? ({ role: "img", "aria-label": ariaLabel } as const) + : ({ "aria-hidden": true } as const); + +/** + * Single entry point for every icon source of the app: app-specific SVGs + * (by name), ui-kit SVG icons (by component) and Material icons (by name, + * as fallback). All variants share the same size/color/className API. + */ +export const Icon = (props: IconProps) => { + const { size, color, className, "aria-label": ariaLabel } = props; + + if (props.icon) { + const SvgIcon = props.icon; + // With no explicit size, neutralize IconSvg's fixed width="24" + // attributes so the icon follows the font-size context instead. + const emSizing = + size === undefined + ? { + width: "1em", + height: "1em", + } + : { className }; + return ( + + ); + } + + if (isAppIconName(props.name)) { + return ( + + ); + } + + return ( + + ); +}; + +/** + * Raw markup of the already fetched icons. Lets a subsequent mount of a + * loaded icon render synchronously instead of flashing an empty placeholder. + */ +const loadedSvgs = new Map(); + +type AppIconProps = IconCommonProps & { name: AppIconName }; + +const AppIcon = ({ + name, + size, + color, + className, + "aria-label": ariaLabel, +}: AppIconProps) => { + const [, rerender] = useReducer((tick: number) => tick + 1, 0); + const markup = loadedSvgs.get(name); + + useEffect(() => { + if (loadedSvgs.has(name)) return; + let cancelled = false; + APP_ICON_MODULES[`./assets/${name}.svg`]() + .then((raw) => { + loadedSvgs.set(name, raw); + if (!cancelled) rerender(); + }) + .catch((error: unknown) => { + console.error(`Failed to load app icon "${name}"`, error); + }); + return () => { + cancelled = true; + }; + }, [name]); + + const fontSize = + size === undefined + ? undefined + : typeof size === "number" + ? size + : getIconSize(size); + + return ( + + ); +}; diff --git a/src/frontend/src/features/ui/components/toaster/_index.scss b/src/frontend/src/features/ui/components/toaster/_index.scss index 544acb94..39ede43b 100644 --- a/src/frontend/src/features/ui/components/toaster/_index.scss +++ b/src/frontend/src/features/ui/components/toaster/_index.scss @@ -1,13 +1,35 @@ -.suite__toaster { - &__wrapper { - // Remove toastify styles - box-shadow: none; - background-color: transparent; - min-height: 0; - width: auto; - padding: 0; - } +@use "../../../../styles/utils/" as *; +// react-toastify only stretches its container below 480px, but our toasts +// switch to bottom-center at the mobile breakpoint (768px): between the two, +// the container stays at fit-content and lets wide toasts overflow the screen. +@media (max-width: breakpoint("mobile")) { + .Toastify__toast-container--bottom-center { + width: 100%; + left: 0; + transform: none; + } +} + +// Remove toastify styles. react-toastify injects its stylesheet at runtime, +// after our bundled CSS: the compound selector outranks its single-class +// rules (.Toastify__toast, .Toastify__toast-theme--light) regardless of +// injection order. +.Toastify__toast.suite__toaster__wrapper { + box-shadow: none; + background-color: transparent; + min-height: 0; + width: auto; + padding: 0; + + @media (max-width: breakpoint("mobile")) { + min-width: 0; + width: 100%; + padding: var(--c--globals--spacings--xs); + } +} + +.suite__toaster { &__item { border-width: 1px; border-style: solid; @@ -22,6 +44,12 @@ font-weight: 500; font-family: var(--c--globals--font--families--base); + @media (max-width: breakpoint("mobile")) { + min-width: 0; + width: 100%; + max-width: 100%; + } + &__content { display: flex; gap: 8px; diff --git a/src/frontend/src/features/ui/components/toaster/index.tsx b/src/frontend/src/features/ui/components/toaster/index.tsx index d1d66aa1..a2e91f3d 100644 --- a/src/frontend/src/features/ui/components/toaster/index.tsx +++ b/src/frontend/src/features/ui/components/toaster/index.tsx @@ -80,17 +80,31 @@ export const ToasterItem = ({ ); }; +// Same breakpoint as `useResponsive().isMobile` (ui-kit) and `breakpoint("mobile")` (SCSS) +const MOBILE_MEDIA_QUERY = "(max-width: 768px)"; + export const addToast = ( children: React.ReactNode, options: Parameters[1] = {} ) => { + const isMobile = + typeof window !== "undefined" && + window.matchMedia(MOBILE_MEDIA_QUERY).matches; + return toast(children, { - position: "bottom-left", + position: isMobile ? "bottom-center" : "bottom-left", closeButton: false, className: "suite__toaster__wrapper", autoClose: 5000, transition: Slide, hideProgressBar: true, + // On mobile the toast is bottom-centered and nearly full-width: the + // horizontal dismiss threshold (80% of the toast width) is out of reach, + // so dismiss with a downward swipe instead (threshold based on height). + // The percent must differ from the default 80: react-toastify silently + // multiplies 80 by 1.5 for the "y" direction (120% of the toast height). + draggableDirection: isMobile ? "y" : "x", + draggablePercent: isMobile ? 40 : 80, ...options, }); }; diff --git a/src/frontend/src/hooks/use-drag-gesture.test.ts b/src/frontend/src/hooks/use-drag-gesture.test.ts new file mode 100644 index 00000000..2be5a49a --- /dev/null +++ b/src/frontend/src/hooks/use-drag-gesture.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { + clampToDirection, + resolveDragEnd, + updateVelocity, +} from "./use-drag-gesture"; + +describe("clampToDirection", () => { + it("keeps only downward/rightward movement when positive", () => { + expect(clampToDirection(40, "positive")).toBe(40); + expect(clampToDirection(-40, "positive")).toBe(0); + }); + + it("keeps only upward/leftward movement when negative", () => { + expect(clampToDirection(-40, "negative")).toBe(-40); + expect(clampToDirection(40, "negative")).toBe(0); + }); + + it("keeps both signs when unconstrained", () => { + expect(clampToDirection(40, undefined)).toBe(40); + expect(clampToDirection(-40, undefined)).toBe(-40); + }); +}); + +describe("updateVelocity", () => { + it("weighs the newest sample most", () => { + // 30px in 16ms ≈ 1.875 px/ms instantaneous. + const velocity = updateVelocity(0, 30, 16); + expect(velocity).toBeCloseTo(1.5, 1); + // A stop (no movement) quickly decays the running value. + expect(updateVelocity(velocity, 0, 16)).toBeCloseTo(0.3, 1); + }); + + it("ignores zero-elapsed samples instead of dividing by zero", () => { + expect(updateVelocity(1.2, 30, 0)).toBe(1.2); + }); +}); + +describe("resolveDragEnd", () => { + const base = { + direction: "positive" as const, + commitDistance: 100, + commitVelocity: 0.5, + }; + + it("commits past the distance threshold regardless of speed", () => { + expect(resolveDragEnd({ ...base, offset: 120, velocity: 0 })).toBe("commit"); + }); + + it("cancels a slow short drag", () => { + expect(resolveDragEnd({ ...base, offset: 40, velocity: 0.1 })).toBe("cancel"); + }); + + it("commits a flick even under the distance threshold", () => { + expect(resolveDragEnd({ ...base, offset: 40, velocity: 1.2 })).toBe("commit"); + }); + + it("only accepts flicks along the configured direction", () => { + expect(resolveDragEnd({ ...base, offset: 0, velocity: -2 })).toBe("cancel"); + expect( + resolveDragEnd({ + ...base, + direction: "negative", + offset: -40, + velocity: -1.2, + }), + ).toBe("commit"); + }); + + it("accepts flicks both ways when unconstrained", () => { + expect( + resolveDragEnd({ ...base, direction: undefined, offset: 0, velocity: -2 }), + ).toBe("commit"); + }); +}); diff --git a/src/frontend/src/hooks/use-drag-gesture.ts b/src/frontend/src/hooks/use-drag-gesture.ts new file mode 100644 index 00000000..5aa38811 --- /dev/null +++ b/src/frontend/src/hooks/use-drag-gesture.ts @@ -0,0 +1,210 @@ +import { useCallback, useRef, useState } from "react"; + +type UseDragGestureOptions = { + /** Which pointer axis the gesture tracks. */ + axis: "x" | "y"; + /** + * Movement sign that arms the gesture: "positive" (down/right), "negative" + * (up/left), or undefined to track both ways. + */ + direction?: "positive" | "negative"; + /** + * Distance (px) beyond which releasing commits. Evaluated at release time + * so it can depend on layout (e.g. a third of an element's height). + */ + commitDistance: number | (() => number); + /** + * Flick velocity (px/ms along the axis, low-pass filtered over the last + * frames) beyond which releasing commits even under the distance. + */ + commitVelocity?: number; + /** Called when the release passes either threshold. */ + onCommit: () => void; + /** Called when the release stays below the thresholds (spring back). */ + onCancel?: () => void; + /** + * Drags starting on elements matching this selector are ignored, so their + * taps keep working (e.g. a close button inside the drag zone). + */ + excludeSelector?: string; + disabled?: boolean; +}; + +type DragDirection = "positive" | "negative" | undefined; + +/** Clamps a movement to the allowed sign for the gesture. */ +export const clampToDirection = (value: number, direction: DragDirection) => { + if (direction === "positive") return Math.max(0, value); + if (direction === "negative") return Math.min(0, value); + return value; +}; + +/** + * Low-pass filtered velocity (px/ms): the flick decision reflects the last + * few frames rather than one noisy sample. + */ +export const updateVelocity = ( + previous: number, + delta: number, + elapsed: number, +) => (elapsed > 0 ? 0.8 * (delta / elapsed) + 0.2 * previous : previous); + +/** Release decision: commit past the distance threshold or on a flick. */ +export const resolveDragEnd = ({ + offset, + velocity, + direction, + commitDistance, + commitVelocity, +}: { + offset: number; + velocity: number; + direction: DragDirection; + commitDistance: number; + commitVelocity: number; +}): "commit" | "cancel" => { + if (Math.abs(offset) > commitDistance) return "commit"; + const flicked = + direction === "positive" + ? velocity > commitVelocity + : direction === "negative" + ? velocity < -commitVelocity + : Math.abs(velocity) > commitVelocity; + return flicked ? "commit" : "cancel"; +}; + +type UseDragGestureResult = { + handlers: { + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + onPointerCancel: (event: React.PointerEvent) => void; + }; + /** + * Signed movement (px) along the axis since the drag started, clamped by + * `direction`. Follows the pointer live, and resets to 0 on release — + * consumers drive their exit animation from their own state, not from the + * last offset. + */ + offset: number; + /** True while a pointer is dragging. */ + isDragging: boolean; +}; + +/** + * Single-axis pointer drag with distance + flick thresholds. Shared by + * swipe-to-close surfaces (Drawer), and meant for the upcoming + * pull-to-refresh and thread-item swipe gestures. The bound element should + * set `touch-action: none` (or the axis-appropriate variant) so the browser + * doesn't claim the moves for scrolling. + */ +export const useDragGesture = ({ + axis, + direction, + commitDistance, + commitVelocity = 0.5, + onCommit, + onCancel, + excludeSelector, + disabled = false, +}: UseDragGestureOptions): UseDragGestureResult => { + const [offset, setOffset] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const dragRef = useRef<{ + pointerId: number; + start: number; + last: number; + lastTime: number; + velocity: number; + } | null>(null); + + const readPosition = useCallback( + (event: React.PointerEvent) => + axis === "y" ? event.clientY : event.clientX, + [axis], + ); + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + if (disabled || dragRef.current) return; + if ( + excludeSelector && + event.target instanceof Element && + event.target.closest(excludeSelector) + ) { + return; + } + const position = readPosition(event); + dragRef.current = { + pointerId: event.pointerId, + start: position, + last: position, + lastTime: event.timeStamp, + velocity: 0, + }; + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // Pointer already gone (or synthetic): tracking still works through + // bubbling events. + } + setIsDragging(true); + }, + [disabled, excludeSelector, readPosition], + ); + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || event.pointerId !== drag.pointerId) return; + const position = readPosition(event); + drag.velocity = updateVelocity( + drag.velocity, + position - drag.last, + event.timeStamp - drag.lastTime, + ); + drag.last = position; + drag.lastTime = event.timeStamp; + setOffset(clampToDirection(position - drag.start, direction)); + }, + [direction, readPosition], + ); + + const onPointerEnd = useCallback( + (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || event.pointerId !== drag.pointerId) return; + dragRef.current = null; + setIsDragging(false); + setOffset(0); + if (event.type === "pointercancel") { + onCancel?.(); + return; + } + const outcome = resolveDragEnd({ + offset: clampToDirection(drag.last - drag.start, direction), + velocity: drag.velocity, + direction, + commitDistance: + typeof commitDistance === "function" ? commitDistance() : commitDistance, + commitVelocity, + }); + if (outcome === "commit") onCommit(); + else onCancel?.(); + }, + [commitDistance, commitVelocity, direction, onCommit, onCancel], + ); + + return { + handlers: { + onPointerDown, + onPointerMove, + onPointerUp: onPointerEnd, + onPointerCancel: onPointerEnd, + }, + offset, + isDragging, + }; +}; + +export default useDragGesture; diff --git a/src/frontend/src/hooks/use-long-press.test.tsx b/src/frontend/src/hooks/use-long-press.test.tsx new file mode 100644 index 00000000..9a5686ba --- /dev/null +++ b/src/frontend/src/hooks/use-long-press.test.tsx @@ -0,0 +1,113 @@ +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useLongPress } from "./use-long-press"; + +// Opts this file into React's act() support; without it every render logs +// "the current testing environment is not configured to support act(...)". +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type Api = ReturnType; + +let container: HTMLDivElement; +let root: Root; +const onLongPress = vi.fn(); + +/** + * Handle on the hook under test. Published from an effect rather than during + * render: writing to module scope while rendering is a side effect the lint + * rules reject. + */ +const hook: { current: Api | null } = { current: null }; +const api = () => hook.current!; + +const Harness = () => { + const value = useLongPress(onLongPress, { + delay: 350, + vibrate: false, + moveTolerance: 10, + }); + useEffect(() => { + hook.current = value; + }); + return null; +}; + +/** Minimal stand-in for the synthetic touch event the handlers read. */ +const touchEvent = (x: number, y: number) => + ({ touches: [{ clientX: x, clientY: y }] }) as unknown as React.TouchEvent; + +const touchStart = (x = 0, y = 0) => + act(() => { + api().handlers.onTouchStart(touchEvent(x, y)); + }); + +const touchMove = (x: number, y: number) => + act(() => { + api().handlers.onTouchMove(touchEvent(x, y)); + }); + +const wait = (ms: number) => + act(() => { + vi.advanceTimersByTime(ms); + }); + +beforeEach(() => { + vi.useFakeTimers(); + onLongPress.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root.render(); + }); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); +}); + +describe("useLongPress", () => { + it("fires after the delay when the finger stays down", () => { + touchStart(20, 40); + wait(349); + expect(onLongPress).not.toHaveBeenCalled(); + wait(1); + expect(onLongPress).toHaveBeenCalledWith({ x: 20, y: 40 }); + }); + + it("survives the small drift of a finger holding still", () => { + touchStart(20, 40); + touchMove(24, 44); + wait(350); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it("aborts once the move reads as a scroll", () => { + touchStart(20, 40); + touchMove(20, 80); + wait(350); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("does not re-arm when the finger comes back after a scroll", () => { + touchStart(20, 40); + touchMove(20, 80); + touchMove(20, 41); + wait(350); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("does not fire again for a move made after the press", () => { + touchStart(20, 40); + wait(350); + touchMove(20, 200); + wait(350); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/frontend/src/hooks/use-long-press.ts b/src/frontend/src/hooks/use-long-press.ts new file mode 100644 index 00000000..06d6692a --- /dev/null +++ b/src/frontend/src/hooks/use-long-press.ts @@ -0,0 +1,96 @@ +import { useCallback, useRef, useState } from "react"; + +import { triggerHaptic } from "@/features/native/haptics"; + +export type LongPressPosition = { x: number; y: number }; + +type UseLongPressOptions = { + /** Delay before the press is considered "long", in milliseconds. */ + delay?: number; + /** Haptic feedback duration when the long press fires, or false to disable. */ + vibrate?: number | false; + /** Movement (px) tolerated before the press is read as a scroll instead. */ + moveTolerance?: number; +}; + +type UseLongPressResult = { + handlers: { + onTouchStart: (event: React.TouchEvent) => void; + onTouchEnd: () => void; + onTouchMove: (event: React.TouchEvent) => void; + onTouchCancel: () => void; + }; + /** True while the finger is down and the long-press timer is still running. */ + pressing: boolean; +}; + +/** + * Detects a touch long-press and reports the initial touch position so callers + * can anchor a context menu where the finger landed. Touch coordinates are + * captured on `touchstart` (the synthetic event is not retained) and a release + * or a move beyond the tolerance before the delay cancels the gesture. + */ +export const useLongPress = ( + onLongPress: (position: LongPressPosition) => void, + { delay = 500, vibrate = 50, moveTolerance = 10 }: UseLongPressOptions = {}, +): UseLongPressResult => { + const timer = useRef | null>(null); + const origin = useRef(null); + const [pressing, setPressing] = useState(false); + + const cancel = useCallback(() => { + origin.current = null; + setPressing(false); + if (timer.current) { + clearTimeout(timer.current); + timer.current = null; + } + }, []); + + const start = useCallback( + (event: React.TouchEvent) => { + const touch = event.touches[0]; + const position: LongPressPosition = touch + ? { x: touch.clientX, y: touch.clientY } + : { x: 0, y: 0 }; + origin.current = position; + setPressing(true); + timer.current = setTimeout(() => { + // Cleared so a move past the delay no longer looks like a running + // press it could cancel. + timer.current = null; + setPressing(false); + if (vibrate) triggerHaptic(vibrate); + onLongPress(position); + }, delay); + }, + [delay, vibrate, onLongPress], + ); + + // A finger never holds perfectly still: aborting on the first `touchmove` + // made the long press practically unreachable. Only a move past the + // tolerance — a scroll — aborts it. + const move = useCallback( + (event: React.TouchEvent) => { + const from = origin.current; + if (!from || !timer.current) return; + const touch = event.touches[0]; + if (!touch) return; + const distance = Math.hypot(touch.clientX - from.x, touch.clientY - from.y); + if (distance > moveTolerance) cancel(); + }, + [moveTolerance, cancel], + ); + + return { + handlers: { + onTouchStart: start, + onTouchEnd: cancel, + onTouchMove: move, + onTouchCancel: cancel, + }, + pressing, + }; +}; + +export default useLongPress; diff --git a/src/frontend/src/hooks/use-refresh-feedback.ts b/src/frontend/src/hooks/use-refresh-feedback.ts new file mode 100644 index 00000000..8590cbae --- /dev/null +++ b/src/frontend/src/hooks/use-refresh-feedback.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMailboxContext } from "@/features/providers/mailbox"; + +/** Minimum time the refreshing state stays on, so the feedback never just flashes. */ +const MIN_REFRESH_MS = 700; + +type UseRefreshFeedbackResult = { + /** True while a refresh is in flight (kept on for at least MIN_REFRESH_MS). */ + isRefreshing: boolean; + /** Result once a refresh settles: "Up to date" or "{{count}} new message". */ + feedback: string | null; + clearFeedback: () => void; + /** + * Refresh the mailbox and compute the new-message feedback. `extra` runs + * alongside the mailboxes refetch (e.g. invalidating the thread list on pull). + */ + refresh: (extra?: () => Promise) => Promise; +}; + +/** + * Refresh the selected mailbox and surface a transient "new messages" feedback. + * + * Shared by the left-panel refresh button and the thread-list pull-to-refresh so + * both report the unread delta the exact same way. + */ +export const useRefreshFeedback = (): UseRefreshFeedbackResult => { + const { t } = useTranslation(); + const { selectedMailbox, refetchMailboxes } = useMailboxContext(); + const [isRefreshing, setIsRefreshing] = useState(false); + const [feedback, setFeedback] = useState(null); + // Snapshot of count_unread_threads at refresh start. Read once isRefreshing + // flips back to false AND React has committed the new mailbox data — at that + // point we know the delta. + const baselineUnreadCountRef = useRef(null); + + useEffect(() => { + if (isRefreshing || baselineUnreadCountRef.current === null) return; + const currentCount = selectedMailbox?.count_unread_threads ?? 0; + const delta = currentCount - baselineUnreadCountRef.current; + baselineUnreadCountRef.current = null; + setFeedback( + delta > 0 + ? t("{{count}} new message", { count: delta }) + : t("Up to date"), + ); + }, [isRefreshing, selectedMailbox?.count_unread_threads, t]); + + const clearFeedback = useCallback(() => setFeedback(null), []); + + const refresh = async (extra?: () => Promise) => { + if (isRefreshing) return; + baselineUnreadCountRef.current = selectedMailbox?.count_unread_threads ?? 0; + setFeedback(null); + setIsRefreshing(true); + try { + await Promise.all([ + refetchMailboxes(), + extra?.(), + new Promise((resolve) => window.setTimeout(resolve, MIN_REFRESH_MS)), + ]); + } finally { + setIsRefreshing(false); + } + }; + + return { isRefreshing, feedback, clearFeedback, refresh }; +}; diff --git a/src/frontend/src/hooks/use-swipe-actions.test.tsx b/src/frontend/src/hooks/use-swipe-actions.test.tsx new file mode 100644 index 00000000..2948f61a --- /dev/null +++ b/src/frontend/src/hooks/use-swipe-actions.test.tsx @@ -0,0 +1,236 @@ +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useSwipeActions, closeSwipedRows } from "./use-swipe-actions"; + +// Opts this file into React's act() support; without it every render logs +// "the current testing environment is not configured to support act(...)". +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const ROW_WIDTH = 400; +const START_WIDTH = 76; +const END_WIDTH = 152; +/** ROW_WIDTH × the default commitRatio. */ +const COMMIT_DISTANCE = 160; + +type Api = ReturnType; + +let container: HTMLDivElement; +let root: Root; +const onCommitStart = vi.fn(); + +const hook: { current: Api | null } = { current: null }; +const api = () => hook.current!; + +const Harness = ({ enabled = true }: { enabled?: boolean }) => { + const value = useSwipeActions({ + startWidth: START_WIDTH, + endWidth: END_WIDTH, + onCommitStart, + enabled, + }); + useEffect(() => { + hook.current = value; + }); + return ( +
+
+
+ ); +}; + +const row = () => container.querySelector('[data-testid="row"]')!; +const content = () => container.querySelector('[data-testid="content"]')!; + +/** + * jsdom ships no TouchEvent constructor, and the gesture only reads `touches` + * and `timeStamp` off the event — a plain Event carrying those is enough. + * Every event shares a timestamp so the velocity stays at 0 and the assertions + * are about travelled distance only. + */ +const dispatchTouch = (type: string, points: { x: number; y: number }[]) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "touches", { + value: points.map(({ x, y }) => ({ clientX: x, clientY: y })), + }); + Object.defineProperty(event, "timeStamp", { value: 0 }); + act(() => { + row().dispatchEvent(event); + }); + return event; +}; + +/** + * The move that locks the axis only arms the gesture — it mounts the action + * panels and rebases the travel on the finger's current position — so a swipe + * needs one move to arm and the following ones to travel. + */ +const swipe = (points: { x: number; y: number }[]) => { + dispatchTouch("touchstart", [points[0]]); + points.slice(1).forEach((point) => dispatchTouch("touchmove", [point])); +}; + +const release = () => dispatchTouch("touchend", []); + +const translateX = () => content().style.transform; +const side = () => row().dataset.side; +const willCommit = () => row().dataset.willCommit; + +const render = (enabled = true) => { + act(() => { + root.render(); + }); + // Rows are measured against their own width; jsdom reports 0 for every + // layout box, which would put the commit threshold at 0 px. + Object.defineProperty(row(), "offsetWidth", { value: ROW_WIDTH, configurable: true }); +}; + +beforeEach(() => { + onCommitStart.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + render(); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); +}); + +describe("useSwipeActions", () => { + it("opens the trailing panel when the swipe passes half its width", () => { + swipe([ + { x: 300, y: 100 }, + { x: 290, y: 100 }, + { x: 190, y: 100 }, + ]); + expect(translateX()).toBe("translate3d(-100px, 0, 0)"); + release(); + expect(api().openSide).toBe("end"); + expect(translateX()).toBe(`translate3d(-${END_WIDTH}px, 0, 0)`); + }); + + it("springs back when the swipe stays short of half the panel", () => { + swipe([ + { x: 300, y: 100 }, + { x: 290, y: 100 }, + { x: 270, y: 100 }, + ]); + release(); + expect(api().openSide).toBeNull(); + expect(translateX()).toBe(""); + }); + + it("waits for the move after the axis lock before displacing the row", () => { + swipe([ + { x: 300, y: 100 }, + { x: 280, y: 100 }, + ]); + // The panels mount on this state change; sliding already would uncover + // an empty gap for a frame. + expect(api().isSwiping).toBe(true); + expect(translateX()).toBe(""); + // Travel is rebased on the lock position, so the row does not jump by + // the threshold it just crossed. + dispatchTouch("touchmove", [{ x: 250, y: 100 }]); + expect(translateX()).toBe("translate3d(-30px, 0, 0)"); + }); + + it("leaves vertical moves to the list scroll", () => { + dispatchTouch("touchstart", [{ x: 300, y: 100 }]); + const move = dispatchTouch("touchmove", [{ x: 295, y: 160 }]); + expect(move.defaultPrevented).toBe(false); + expect(api().isSwiping).toBe(false); + expect(translateX()).toBe(""); + release(); + expect(api().openSide).toBeNull(); + }); + + it("claims the gesture once the horizontal axis is locked", () => { + dispatchTouch("touchstart", [{ x: 300, y: 100 }]); + const move = dispatchTouch("touchmove", [{ x: 340, y: 105 }]); + expect(move.defaultPrevented).toBe(true); + }); + + it("publishes the side being pulled, so only its panel shows", () => { + expect(side()).toBeFalsy(); + swipe([ + { x: 300, y: 100 }, + { x: 290, y: 100 }, + { x: 190, y: 100 }, + ]); + expect(side()).toBe("end"); + dispatchTouch("touchmove", [{ x: 350, y: 100 }]); + expect(side()).toBe("start"); + release(); + }); + + it("runs the leading action when the row is pulled past the commit threshold", () => { + swipe([ + { x: 50, y: 100 }, + { x: 60, y: 100 }, + { x: 60 + COMMIT_DISTANCE + 10, y: 100 }, + ]); + expect(willCommit()).toBe("true"); + release(); + expect(onCommitStart).toHaveBeenCalledTimes(1); + // Read/unread does not remove the row: it snaps back into place. + expect(api().openSide).toBeNull(); + expect(translateX()).toBe(""); + expect(willCommit()).toBe("false"); + }); + + it("only reveals the leading action when the pull stops short", () => { + swipe([ + { x: 50, y: 100 }, + { x: 60, y: 100 }, + { x: 110, y: 100 }, + ]); + // Never armed, so the attribute was never written at all. + expect(willCommit()).not.toBe("true"); + release(); + expect(onCommitStart).not.toHaveBeenCalled(); + expect(api().openSide).toBe("start"); + expect(translateX()).toBe(`translate3d(${START_WIDTH}px, 0, 0)`); + }); + + it("damps the pull past the trailing panel instead of following the finger", () => { + swipe([ + { x: 300, y: 100 }, + { x: 290, y: 100 }, + { x: 90, y: 100 }, + ]); + // 200px of travel for a 152px panel: the extra 48px are resisted. + expect(translateX()).toBe("translate3d(-166.4px, 0, 0)"); + }); + + it("closes on demand, whatever left it open", () => { + swipe([ + { x: 300, y: 100 }, + { x: 290, y: 100 }, + { x: 190, y: 100 }, + ]); + release(); + expect(api().openSide).toBe("end"); + act(() => closeSwipedRows()); + expect(api().openSide).toBeNull(); + expect(translateX()).toBe(""); + expect(side()).toBe(""); + }); + + it("ignores touches when disabled", () => { + render(false); + swipe([ + { x: 300, y: 100 }, + { x: 290, y: 100 }, + { x: 190, y: 100 }, + ]); + release(); + expect(api().openSide).toBeNull(); + expect(translateX()).toBe(""); + }); +}); diff --git a/src/frontend/src/hooks/use-swipe-actions.ts b/src/frontend/src/hooks/use-swipe-actions.ts new file mode 100644 index 00000000..1b6cd589 --- /dev/null +++ b/src/frontend/src/hooks/use-swipe-actions.ts @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { triggerHaptic } from "@/features/native/haptics"; +import { updateVelocity } from "./use-drag-gesture"; + +/** + * Which action panel a row reveals: "start" sits before the row (revealed by + * swiping right), "end" after it (revealed by swiping left). + */ +export type SwipeSide = "start" | "end"; + +type UseSwipeActionsOptions = { + /** Reveal width (px) of the leading panel; 0 disables swiping right. */ + startWidth: number; + /** Reveal width (px) of the trailing panel; 0 disables swiping left. */ + endWidth: number; + /** + * Fraction of the row width past which releasing runs the leading action + * straight away, instead of leaving the panel open. + */ + commitRatio?: number; + /** Runs when the release passes the commit threshold (leading side only). */ + onCommitStart?: () => void; + enabled?: boolean; +}; + +type UseSwipeActionsResult = { + /** Ref for the touch surface — the whole row. */ + containerRef: (node: HTMLElement | null) => void; + /** Ref for the element translated by the gesture — the row content. */ + contentRef: (node: HTMLElement | null) => void; + /** Panel currently held open, or null when the row sits at rest. */ + openSide: SwipeSide | null; + /** True while a finger drives the row horizontally. */ + isSwiping: boolean; + /** Snaps the row back to rest (animated). */ + close: () => void; +}; + +/** + * Movement (px) before the gesture decides between scrolling and swiping. + * Shared with the pull-to-refresh gesture, which arbitrates the same finger: + * were the two thresholds to drift apart, a diagonal move could arm both. + */ +export const AXIS_LOCK_THRESHOLD = 8; +/** Damping applied past a panel's reveal width, so the pull feels elastic. */ +const OVERSHOOT_RESISTANCE = 0.3; +/** Flick velocity (px/ms) that opens or commits regardless of distance. */ +const FLICK_VELOCITY = 0.5; + +/** + * Rows opened by a swipe, so opening one closes the others. A module-level + * registry rather than React state on purpose: closing a sibling must not + * re-render the whole thread list on every gesture. + */ +const openRows = new Set<() => void>(); + +/** Closes every open row — e.g. when the list scrolls under the finger. */ +export const closeSwipedRows = () => { + openRows.forEach((close) => close()); +}; + +/** + * Horizontal swipe-to-reveal gesture for a list row. + * + * Raw touch listeners (not React handlers) because the gesture needs a + * non-passive `touchmove` to suppress the vertical scroll once the horizontal + * axis is locked — React attaches touch listeners passively. + * + * **Nothing about the moving row goes through React state.** The translation, + * the side being pulled and the commit threshold are all written straight to + * the DOM as inline transforms and data attributes, which the stylesheet reads. + * A row carries a lot of markup, and re-rendering it on every frame is what + * makes the swipe stutter on low-end devices. React only hears about the two + * discrete transitions of the gesture — it starting, and the row settling + * open or closed — which is what mounts and unmounts the action panels. + */ +export const useSwipeActions = ({ + startWidth, + endWidth, + commitRatio = 0.4, + onCommitStart, + enabled = true, +}: UseSwipeActionsOptions): UseSwipeActionsResult => { + const [container, setContainer] = useState(null); + const [openSide, setOpenSide] = useState(null); + const [isSwiping, setIsSwiping] = useState(false); + + const containerNode = useRef(null); + const contentNode = useRef(null); + const offsetRef = useRef(0); + const willCommitRef = useRef(false); + + // Mirrors of the props read inside the long-lived listeners, so the + // listeners stay attached across renders instead of being torn down and + // rebound mid-gesture. + const optionsRef = useRef({ startWidth, endWidth, commitRatio, onCommitStart }); + optionsRef.current = { startWidth, endWidth, commitRatio, onCommitStart }; + + /** + * Writes the whole visual state of the row in one go. `data-side` lets the + * stylesheet hide the panel that is *not* being pulled: both are laid out + * across the full width, so without it a long pull uncovers the far side's + * buttons behind the row. + */ + const setOffset = useCallback((value: number, animate: boolean) => { + offsetRef.current = value; + const content = contentNode.current; + if (content) { + // Emptying the inline transition hands the property back to the + // stylesheet, which owns the snap-back easing. + content.style.transition = animate ? "" : "none"; + content.style.transform = value === 0 ? "" : `translate3d(${value}px, 0, 0)`; + } + const node = containerNode.current; + if (node) { + const side = value > 0 ? "start" : value < 0 ? "end" : ""; + if (node.dataset.side !== side) node.dataset.side = side; + } + }, []); + + const setWillCommit = useCallback((value: boolean) => { + if (willCommitRef.current === value) return; + willCommitRef.current = value; + const node = containerNode.current; + if (node) node.dataset.willCommit = String(value); + if (value) triggerHaptic(10, "LIGHT"); + }, []); + + const close = useCallback(() => { + setOffset(0, true); + setWillCommit(false); + setOpenSide(null); + }, [setOffset, setWillCommit]); + + const setContainerNode = useCallback((node: HTMLElement | null) => { + containerNode.current = node; + setContainer(node); + }, []); + + const setContentNode = useCallback((node: HTMLElement | null) => { + contentNode.current = node; + // Re-applied because the panels mounting (or a list refetch) can hand + // us a fresh node while the row is already displaced. + if (node && offsetRef.current !== 0) { + node.style.transform = `translate3d(${offsetRef.current}px, 0, 0)`; + } + }, []); + + // A row that closes for any reason (release, sibling opening, unmount) + // must leave the registry, otherwise `closeSwipedRows` keeps calling into + // a dead component. + useEffect(() => { + if (openSide === null) return; + openRows.add(close); + return () => { + openRows.delete(close); + }; + }, [openSide, close]); + + useEffect(() => { + if (!enabled || !container) return; + + let axis: "x" | "y" | null = null; + let startX = 0; + let startY = 0; + let baseOffset = 0; + let lastX = 0; + let lastTime = 0; + let velocity = 0; + let committing = false; + + /** Clamps the raw finger travel to what each side allows to show. */ + const resolveOffset = (raw: number) => { + const { startWidth: start, endWidth: end } = optionsRef.current; + if (raw > 0) { + if (start === 0) return 0; + // The leading side stays free past its reveal width: that + // travel is what arms the commit. + return Math.min(raw, container.offsetWidth); + } + if (end === 0) return 0; + // The trailing side only reveals its buttons, so anything past + // them is pure elastic feedback. + return raw < -end ? -end - (-end - raw) * OVERSHOOT_RESISTANCE : raw; + }; + + const commitDistance = () => + container.offsetWidth * optionsRef.current.commitRatio; + + const onTouchStart = (event: TouchEvent) => { + if (committing || event.touches.length > 1) return; + axis = null; + startX = event.touches[0].clientX; + startY = event.touches[0].clientY; + lastX = startX; + lastTime = event.timeStamp; + velocity = 0; + baseOffset = offsetRef.current; + }; + + const onTouchMove = (event: TouchEvent) => { + if (committing || event.touches.length > 1) return; + const { clientX, clientY } = event.touches[0]; + + if (axis === null) { + const dx = Math.abs(clientX - startX); + const dy = Math.abs(clientY - startY); + if (Math.max(dx, dy) < AXIS_LOCK_THRESHOLD) return; + axis = dx > dy ? "x" : "y"; + if (axis === "y") { + // The finger is scrolling: give the row back and let any + // open panel close, as tapping elsewhere would. + if (offsetRef.current !== 0) close(); + return; + } + // Sibling rows must not stay open behind this one. + openRows.forEach((closeRow) => { + if (closeRow !== close) closeRow(); + }); + // Mounts the action panels, and only then. Keeping them in the + // DOM of every row at rest costs hundreds of nodes the list + // never uses. + setIsSwiping(true); + // The row starts moving on the *next* move, from where the + // finger is now: sliding it before the panels have mounted + // would uncover an empty gap for a frame, and rebasing here + // avoids the row jumping by the lock threshold. + startX = clientX; + baseOffset = offsetRef.current; + event.preventDefault(); + return; + } + if (axis !== "x") return; + + // Claim the gesture: without this the browser keeps scrolling the + // list vertically on the diagonal part of the swipe. + event.preventDefault(); + velocity = updateVelocity(velocity, clientX - lastX, event.timeStamp - lastTime); + lastX = clientX; + lastTime = event.timeStamp; + + const offset = resolveOffset(baseOffset + clientX - startX); + setOffset(offset, false); + setWillCommit(offset > commitDistance()); + }; + + const onTouchEnd = () => { + if (axis !== "x") return; + axis = null; + setIsSwiping(false); + const offset = offsetRef.current; + const { startWidth: start, endWidth: end, onCommitStart: onCommit } = + optionsRef.current; + + if (offset > 0) { + if (offset > commitDistance() || velocity > FLICK_VELOCITY) { + // Guard against a second gesture landing between the + // snap-back and the mutation settling. + committing = true; + close(); + onCommit?.(); + setTimeout(() => { + committing = false; + }, 0); + return; + } + if (offset > start / 2) { + setOffset(start, true); + setOpenSide("start"); + return; + } + } else if (offset < 0 && (-offset > end / 2 || velocity < -FLICK_VELOCITY)) { + setOffset(-end, true); + setOpenSide("end"); + return; + } + close(); + }; + + container.addEventListener("touchstart", onTouchStart, { passive: true }); + container.addEventListener("touchmove", onTouchMove, { passive: false }); + container.addEventListener("touchend", onTouchEnd); + container.addEventListener("touchcancel", onTouchEnd); + + return () => { + container.removeEventListener("touchstart", onTouchStart); + container.removeEventListener("touchmove", onTouchMove); + container.removeEventListener("touchend", onTouchEnd); + container.removeEventListener("touchcancel", onTouchEnd); + }; + }, [container, enabled, close, setOffset, setWillCommit]); + + return { + containerRef: setContainerNode, + contentRef: setContentNode, + openSide, + isSwiping, + close, + }; +}; + +export default useSwipeActions; diff --git a/src/frontend/src/routes/__root.tsx b/src/frontend/src/routes/__root.tsx index ceb48bbf..78ddd4a2 100644 --- a/src/frontend/src/routes/__root.tsx +++ b/src/frontend/src/routes/__root.tsx @@ -2,12 +2,12 @@ import { createRootRoute, Outlet } from "@tanstack/react-router"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; - import { queryClient } from "@/features/api/query-client"; import { Auth } from "@/features/auth"; import { ConfigProvider } from "@/features/providers/config"; import ErrorBoundary from "@/features/errors/error-boundary"; import ThemeProvider from "@/features/providers/theme"; +import { isNativePlatform } from "@/features/native/platform"; // Each route owns its document title through `useDocumentTitle`: a title set // here would run after the routes' effects (React runs child effects first) @@ -15,8 +15,12 @@ import ThemeProvider from "@/features/providers/theme"; const RootShell = () => { return ( - - + {!isNativePlatform() && ( + <> + + + + )} diff --git a/src/frontend/src/routes/index.tsx b/src/frontend/src/routes/index.tsx index 5db6bba3..25d86adb 100644 --- a/src/frontend/src/routes/index.tsx +++ b/src/frontend/src/routes/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute, useSearch } from "@tanstack/react-router"; import { useTranslation } from "react-i18next"; -import { Hero, HomeGutter, Footer, ProConnectButton } from "@gouvfr-lasuite/ui-kit"; +import { Hero, HomeGutter, Footer, ProConnectButton, useResponsive } from "@gouvfr-lasuite/ui-kit"; import { login, useAuth } from "@/features/auth"; import { MainLayout } from "@/features/layouts/components/main"; @@ -16,7 +16,10 @@ const HomePage = () => { const { t } = useTranslation(); const { theme, variant, themeConfig } = useTheme(); const { user } = useAuth(); + const { isDesktop } = useResponsive(); const searchParams = useSearch({ strict: false }) as { next?: string }; + const headerIconName = isDesktop ? `app-logo-${variant}` : `app-icon-${variant}`; + const heroIconName = isDesktop ? `app-icon-${variant}` : `app-logo-${variant}`; useDocumentTitle(); @@ -34,12 +37,14 @@ const HomePage = () => { hideLeftPanelOnDesktop leftPanelContent={} rightHeaderContent={} - icon={{t("logo")}} + icon={ + {t("logo")} + } >