(frontend) mobile UI overhaul

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.
This commit is contained in:
jbpenrath
2026-08-13 12:14:34 +02:00
parent 9066c50961
commit cf67ad0b4f
162 changed files with 9391 additions and 2322 deletions
@@ -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')
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -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')
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+1 -1
View File
@@ -27,7 +27,7 @@
<meta name="application-name" content="Messaging" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="format-detection" content="telephone=no" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="description" content="Messaging" />
<link rel="apple-touch-startup-image" href="/images/pwa/splash/apple-splash-2048-2732.jpg" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="/images/pwa/splash/apple-splash-2732-2048.jpg" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" />
@@ -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")
+1203 -1079
View File
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -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",
+11 -4
View File
@@ -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",
+11 -4
View File
@@ -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",
+5
View File
@@ -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).
@@ -139,3 +139,10 @@
}
}
}
// Specific styles for native platforms
:root.native {
& .bn-editor {
padding-block: var(--c--globals--spacings--base);
}
}
@@ -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 = <BSchema extends BlockSchema, ISchema extends
sideMenu={false}
slashMenu={false}
formattingToolbar={false}
// The default link toolbar is a hover popover; on touch it
// only shows up once the link is tapped, and the tap itself
// navigates. The mobile toolbar replaces it with its own
// link editor, opened by tapping the link (see MobileToolbar).
linkToolbar={!isNativePlatform()}
{...composerProps}
className={clsx(composerProps.className, "composer-field-input")}
editable={!disabled}
@@ -11,7 +11,7 @@ import { EmailExporter } from '@/features/blocknote/email-exporter';
import { blocksToPlainText } from '@/features/blocknote/markdown-exporter';
import { useConfig } from '@/features/providers/config';
import MailHelper from '@/features/utils/mail-helper';
import { backfillTemplateVariableContent, createBlockNoteDictionary, createNonImageFileBlockers, dropUnsupportedBlocks } from '@/features/blocknote/utils';
import { backfillTemplateVariableContent, createBlockNoteDictionary, createNonImageFileBlockers, dropUnsupportedBlocks, createNativeLinkOptions } from '@/features/blocknote/utils';
import { PasteColorSanitizer } from '@/features/blocknote/paste-sanitizer';
import { handle } from '@/features/utils/errors';
@@ -128,6 +128,7 @@ export const useBase64Composer = <
trailingBlock,
uploadFile,
dictionary: createBlockNoteDictionary(locale, t),
...createNativeLinkOptions(),
...blockNoteOptions,
_tiptapOptions: {
extensions: [...(extensions ?? []), PasteColorSanitizer],
@@ -1,42 +1,61 @@
import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core";
import { BlockNoteEditor, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema } from "@blocknote/core";
import { useBlockNoteEditor, useComponentsContext, useEditorState } from "@blocknote/react";
import { useTranslation } from "react-i18next";
import { Icon, IconSize } from "@gouvfr-lasuite/ui-kit";
// Default-schema typing, matching what `useBlockNoteEditor()` returns without
// explicit generics — the composers' extended schemas degrade to this.
type Editor = BlockNoteEditor<
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema
>;
/**
* 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<BlockSchema, InlineContentSchema, StyleSchema>();
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 (
<Components.FormattingToolbar.Button
icon={<Icon name="image" size={IconSize.SMALL} />}
label={t("Insert image")}
mainTooltip={t("Insert image")}
onClick={handleClick}
onClick={() => insertImageBlock(editor)}
/>
);
};
@@ -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 `<p>{leading}[{token}]{trailing}</p>` and locates the token node. */
const buildDoc = (leading: string, token: string, trailing: string): Built => {
/**
* Builds `<p>{leading}[{token}]{trailing}</p>` 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<string, (props: { editor: Editor }) => 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<string, unknown>;
expect(typeof domHandlers.beforeinput).toBe('function');
});
});
@@ -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",
);
},
}),
];
@@ -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 }) => (
<span data-inline-type={TEMPLATE_VARIABLE_TYPE} ref={contentRef} />
<span
data-inline-type={TEMPLATE_VARIABLE_TYPE}
spellCheck={false}
ref={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<BlockSchema, TemplateVariableInlineContentSchema, StyleSchema>();
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 (
<MobileToolbarButton
icon={<Spinner size="sm" />}
label={t("Loading variables...")}
isDisabled
onClick={() => {}}
/>
);
}
return (
<Components.FormattingToolbar.Button
icon={<Spinner size="sm" />}
@@ -94,6 +161,49 @@ export const TemplateVariableSelector = ({ variables, isLoading }: TemplateVaria
return null;
}
if (mobileDrawer) {
return (
<>
<MobileToolbarButton
icon={<Icon name="space_bar" size={IconSize.MEDIUM} />}
label={t("Variables")}
isActive={mobileDrawer.openId === VARIABLES_DRAWER_ID}
onClick={() => mobileDrawer.open(VARIABLES_DRAWER_ID)}
/>
{mobileDrawer.openId === VARIABLES_DRAWER_ID &&
mobileDrawer.slot &&
createPortal(
<Drawer title={t("Variables")} onClose={mobileDrawer.close}>
<div className="drawer-list">
{variableItems.map((item) => (
<button
type="button"
key={item.text}
className="drawer-list__item"
onClick={() => {
// Close first: the caret insertion must happen in a
// focused editor. Inserting inline content while the
// editor is blurred with inputmode="none" and focusing
// right after makes the Android IME restart composition
// on the freshly-mutated text and hang the webview.
mobileDrawer.close();
item.onClick();
}}
>
<Icon name="space_bar" size={IconSize.MEDIUM} />
<span className="drawer-list__item-label">
{item.text}
</span>
</button>
))}
</div>
</Drawer>,
mobileDrawer.slot,
)}
</>
);
}
return (
<Components.FormattingToolbar.Select
key={"templateVariableSelector"}
@@ -1,7 +1,11 @@
import { useBlockNoteEditor, useComponentsContext, useEditorState } from "@blocknote/react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
import { Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react";
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 { MessageTemplateTypeChoices, ReadMessageTemplate, useMailboxesMessageTemplatesAvailableList, draftPlaceholdersRetrieve, DraftPlaceholdersRetrieve200 } from "@/features/api/gen";
import { MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema, PartialMessageComposerBlockSchema } from "@/features/forms/components/message-composer";
import { useModal } from "@gouvfr-lasuite/cunningham-react";
@@ -9,6 +13,8 @@ import { handle } from "@/features/utils/errors";
import MailHelper from "@/features/utils/mail-helper";
import { resolveTemplateVariables } from "@/features/blocknote/utils";
const TEMPLATES_DRAWER_ID = "message-templates";
type MessageTemplateSelectorProps = {
mailboxId: string;
messageId?: string;
@@ -25,6 +31,9 @@ export const MessageTemplateSelector = ({ mailboxId, messageId, ensureDraft, upl
const editor = useBlockNoteEditor<MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema>();
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 (
<MobileToolbarButton
icon={<Spinner size="sm" />}
label={t("Loading templates...")}
isDisabled
onClick={() => {}}
/>
);
}
return (
<Components.FormattingToolbar.Button
icon={<Spinner size="sm" />}
@@ -155,6 +174,50 @@ export const MessageTemplateSelector = ({ mailboxId, messageId, ensureDraft, upl
return null;
}
if (mobileDrawer) {
return (
<>
<MobileToolbarButton
icon={<Icon name="description" size={IconSize.MEDIUM} />}
label={t("Insert template")}
isActive={mobileDrawer.openId === TEMPLATES_DRAWER_ID}
onClick={() => mobileDrawer.open(TEMPLATES_DRAWER_ID)}
/>
{mobileDrawer.openId === TEMPLATES_DRAWER_ID &&
mobileDrawer.slot &&
createPortal(
<Drawer
title={t("Insert template")}
onClose={mobileDrawer.close}
>
<div className="drawer-list">
{templates.map((template) => (
<button
type="button"
key={template.id}
className="drawer-list__item"
onClick={() => {
// Close first: the keyboard comes
// back while the async insertion
// (placeholders, images) settles.
mobileDrawer.close();
void handleSelect(template);
}}
>
<Icon name="description" size={IconSize.MEDIUM} />
<span className="drawer-list__item-label">
{template.name}
</span>
</button>
))}
</div>
</Drawer>,
mobileDrawer.slot,
)}
</>
);
}
return (
<>
<Components.FormattingToolbar.Button
@@ -0,0 +1,271 @@
// The composer's formatting toolbar on the native app, shown as a bar pinned
// above the on-screen keyboard while the composer is focused (see
// mobile-toolbar/index.tsx). The keyboard resizes the webview itself (iOS:
// @capacitor/keyboard native resize, Android: adjustResize), so a fixed
// bottom: 0 lands right on top of it.
// `:root.native` (not `.native`) so the inner rules outweigh the composer's
// inline-toolbar styles (.composer-field .composer-field-input …) that wrap
// buttons and stretch the bar to 100%.
:root.native .bn-mobile-formatting-toolbar {
position: fixed;
left: var(--c--globals--spacings--3xs);
right: var(--c--globals--spacings--3xs);
bottom: var(--c--globals--spacings--3xs);
// Above Cunningham/React modals (.ReactModalPortal is z-index 999999): the
// signature / template composers live inside settings modals and their
// toolbar must stay visible. Below react-toastify (--toastify-z-index).
z-index: 1000000;
display: flex;
// The "Aa" format panel stacks above the buttons row.
flex-direction: column;
// Override the overflow-x: auto from @blocknote/react's base rule (loaded
// through @blocknote/mantine/style.css): the bar is the containing block of
// the inline dropdowns (template/signature selectors…), and an overflow on
// the containing block itself would clip them. Scrolling belongs to the
// inner toolbar below.
overflow: visible;
// No background or border here: the container also spans the drawer area,
// and a solid backdrop would stay put while the drawer sheet slides down
// (a white slab during the exit animation). Each bottom row paints its own
// background — and carries the safe-area padding — instead.
// Floating-bar chrome shared by the two bottom rows: the buttons toolbar
// and the link editor that replaces it (see MobileLinkEditor).
.bn-toolbar.bn-formatting-toolbar,
.mobile-toolbar__link-editor {
border-radius: 8px;
border: 1px solid var(--c--contextuals--border--surface--primary);
// Solid at the top fading out towards the keyboard, so the buttons read
// against the content scrolling behind (plain background kept as the
// fallback for engines without color-mix).
background: var(--c--contextuals--background--surface--secondary);
background: linear-gradient(
to bottom,
var(--c--contextuals--background--surface--secondary) 0%,
color-mix(in srgb, var(--c--contextuals--background--surface--secondary) 80%, transparent) 50%,
transparent 100%
);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding-top: var(--c--globals--spacings--3xs);
padding-bottom: calc(var(--c--globals--spacings--3xs) + var(--safe-area-inset-bottom));
padding-inline: var(--c--globals--spacings--3xs);
box-sizing: border-box;
}
// Drawers opened from the bar are true bottom sheets: escape the bar's
// floating inset so they span edge to edge instead of inheriting the
// toolbar's margins.
.drawer {
margin-inline: calc(-1 * var(--c--globals--spacings--3xs));
}
// The format drawer is the bottom-most surface while open (the buttons
// row folds away, see MobileToolbar). Anchored to the viewport itself
// rather than laid out inside the bar, so none of the bar's insets can
// leave a gap under it; its sheet clears the home-indicator area. The
// bar has no transform/filter, so fixed still resolves against the
// viewport here.
.mobile-toolbar__format-drawer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
margin: 0;
.drawer__sheet {
padding-bottom: var(--safe-area-inset-bottom);
}
}
// The inner toolbar — not the bar itself — is the horizontal scroller: the
// selectors' dropdowns render inline (BlockNote hardcodes withinPortal:
// false) and are absolutely positioned against this fixed bar, so they
// escape the static inner scroller's clip and can open above the keyboard
// instead of being cut off.
.bn-toolbar.bn-formatting-toolbar {
flex: 1 1 auto;
width: 100%;
min-width: 0;
max-width: none;
flex-wrap: nowrap;
overflow-x: auto;
touch-action: pan-x;
-webkit-overflow-scrolling: touch;
// Settle flings on a button boundary instead of mid-icon; proximity keeps
// free scrolling in between so it doesn't feel paginated.
scroll-snap-type: x proximity;
scroll-padding-inline: var(--c--globals--spacings--3xs);
> * {
// 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 <body>, 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;
}
}
@@ -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) => (
<button
type="button"
className={clsx("mobile-toolbar__button", {
"mobile-toolbar__button--active": isActive,
})}
aria-label={label}
aria-pressed={isActive}
disabled={isDisabled}
onClick={onClick}
>
{icon}
{text && <span className="mobile-toolbar__button-text">{text}</span>}
</button>
);
/**
* 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<DefaultStyleSchema> => {
const storedMarks = editor._tiptapEditor.state.storedMarks;
if (!storedMarks) return editor.getActiveStyles();
const styles: Record<string, boolean | string> = {};
// 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<DefaultStyleSchema>;
};
const STYLE_TOGGLES = {
bold: { bold: true },
italic: { italic: true },
underline: { underline: true },
strike: { strike: true },
} as const satisfies Record<string, Styles<DefaultStyleSchema>>;
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 (
<MobileToolbarButton
icon={icon}
label={editor.dictionary.formatting_toolbar[style].tooltip}
isActive={isActive}
// No editor.focus() in these handlers: in the bar the capture-
// phase mousedown guard already kept the editor focused, and in
// the format panel the keyboard is deliberately dismissed —
// commands apply to the state selection without DOM focus.
onClick={() => 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<string, "string" | "number" | "boolean">,
),
),
[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<string, unknown>)[name] === value,
);
const ItemIcon = item.icon;
return (
<MobileToolbarButton
icon={<ItemIcon size={20} />}
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<string, unknown>)
.textAlignment as string,
blocks: selectedBlocks,
};
},
});
if (!state) return null;
const AlignIcon = ALIGNMENT_ICONS[alignment];
return (
<MobileToolbarButton
icon={<AlignIcon size={20} />}
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 },
});
}
}
});
}}
/>
);
};
@@ -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<MobileToolbarDrawerApi | null>(null);
export const useMobileToolbarDrawer = () =>
useContext(MobileToolbarDrawerContext);
@@ -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 (
<button
type="button"
className={clsx("mobile-toolbar__swatch", {
"mobile-toolbar__swatch--active": activeColor === color,
})}
{...{ [dataKey]: color }}
data-style-type={styleType}
data-value={color}
aria-label={`${title}: ${editor.dictionary.color_picker.colors[color]}`}
aria-pressed={activeColor === color}
onClick={() => {
if (color === "default") editor.removeStyles(styles);
else editor.addStyles(styles);
}}
>
A
</button>
);
};
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<HTMLDivElement>(null);
useEffect(() => {
blockListRef.current
?.querySelector('[aria-pressed="true"]')
?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, []);
return (
<div className="mobile-toolbar__panel">
<div className="mobile-toolbar__panel-inline" ref={blockListRef}>
{blockItems.map((item) => (
<BlockTypeButton
key={`${item.type}-${item.props?.level ?? ""}`}
item={item}
showText
/>
))}
</div>
<div className="mobile-toolbar__panel-row">
{/* Bold/italic/underline are repeated from the buttons row:
that row folds away while this panel is open. */}
<StyleToggleButton style="bold" icon={<RiBold size={20} />} />
<StyleToggleButton style="italic" icon={<RiItalic size={20} />} />
<StyleToggleButton
style="underline"
icon={<RiUnderline size={20} />}
/>
<StyleToggleButton
style="strike"
icon={<RiStrikethrough size={20} />}
/>
<ToolbarSeparator />
{canInsertImage && (
<MobileToolbarButton
icon={<Icon name="image" size={IconSize.MEDIUM} />}
label={t("Insert image")}
onClick={() => {
insertImageBlock(editor);
onInsert();
}}
/>
)}
{canInsertColumns && (
<MobileToolbarButton
icon={
<Icon
name="vertical_split"
size={IconSize.MEDIUM}
style={{ transform: "rotate(180deg)" }}
/>
}
label={t("Insert 2 columns")}
onClick={() => {
const currentBlock = editor.getTextCursorPosition().block;
editor.insertBlocks(
[createColumnListBlock()],
currentBlock,
"after",
);
onInsert();
}}
/>
)}
{(canInsertImage || canInsertColumns) && <ToolbarSeparator />}
<TextAlignMobileButton alignment="left" />
<TextAlignMobileButton alignment="center" />
<TextAlignMobileButton alignment="right" />
</div>
{"textColor" in editor.schema.styleSchema && (
<div className="mobile-toolbar__panel-row mobile-toolbar__panel-colors">
{SWATCH_COLORS.map((color) => (
<ColorSwatchButton
key={color}
styleType="textColor"
color={color}
/>
))}
</div>
)}
{"backgroundColor" in editor.schema.styleSchema && (
<div className="mobile-toolbar__panel-row mobile-toolbar__panel-colors">
{SWATCH_COLORS.map((color) => (
<ColorSwatchButton
key={color}
styleType="backgroundColor"
color={color}
/>
))}
</div>
)}
</div>
);
};
@@ -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<ComposerFocusState>(() =>
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);
}, []);
@@ -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<MobileToolbarView>("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<string | null>(null);
const [drawerSlot, setDrawerSlot] = useState<HTMLDivElement | null>(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 <body>: 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(
<div
className="bn-mobile-formatting-toolbar bn-root bn-mantine light"
data-color-scheme="light"
ref={keepEditorFocusOnTap}
>
{view === "format" && (
<Drawer
className="mobile-toolbar__format-drawer"
title={t("Format")}
onClose={() => {
setView("toolbar");
// Closing the panel hands back to typing, keyboard
// included.
releaseKeyboardToEditor(editor);
}}
>
<MobileFormatPanel onInsert={() => setView("toolbar")} />
</Drawer>
)}
{/* Children portal their own Drawer here (display: contents, so
it joins the bar's flex column right above the toolbar row). */}
<div className="mobile-toolbar__drawer-slot" ref={setDrawerSlot} />
{view === "link" && (
<MobileLinkEditor onClose={() => 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" && (
<FormattingToolbar>
<MobileToolbarButton
icon={<Icon icon={KeyboardHide} size={IconSize.MEDIUM} />}
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}
/>
<ToolbarSeparator />
<MobileToolbarButton
icon={<Icon icon={FormatText} size={IconSize.MEDIUM} />}
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();
}}
/>
<StyleToggleButton style="bold" icon={<RiBold size={20} />} />
<StyleToggleButton style="italic" icon={<RiItalic size={20} />} />
<StyleToggleButton
style="underline"
icon={<RiUnderline size={20} />}
/>
<MobileToolbarButton
icon={<RiLink size={20} />}
label={editor.dictionary.formatting_toolbar.link.tooltip}
isActive={hasLink}
onClick={() => setView("link")}
/>
{bulletListItem && <BlockTypeButton item={bulletListItem} />}
{quoteItem && <BlockTypeButton item={quoteItem} />}
<FileCaptionButton key={"fileCaptionButton"} />
<FileReplaceButton key={"fileReplaceButton"} />
<FileDeleteButton key={"fileDeleteButton"} />
<FilePreviewButton key={"filePreviewButton"} />
{children && (
<MobileToolbarDrawerContext.Provider value={drawerApi}>
<ToolbarSeparator />
{children}
</MobileToolbarDrawerContext.Provider>
)}
</FormattingToolbar>
)}
</div>,
document.body,
);
};
@@ -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 (
<div className="mobile-toolbar__link-editor">
<MobileToolbarButton
icon={<RiArrowLeftSLine size={24} />}
label={t("Cancel")}
onClick={closeAndRefocus}
/>
<input
className="mobile-toolbar__link-input"
type="url"
autoFocus
value={url}
onChange={(event) => 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 && (
<MobileToolbarButton
icon={<RiLinkUnlinkM size={20} />}
label={t("Remove link")}
onClick={remove}
/>
)}
<MobileToolbarButton
icon={<RiCheckLine size={24} />}
label={t("Apply")}
isDisabled={!url.trim() && !initialUrl}
onClick={apply}
/>
</div>
);
};
@@ -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<MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema>();
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 (
<MobileToolbarButton
icon={<Spinner size="sm" />}
label={t("Loading templates...")}
isDisabled
onClick={() => {}}
/>
);
}
return (
<Components.FormattingToolbar.Button
icon={<Spinner size="sm" />}
@@ -110,6 +131,16 @@ export const SignatureTemplateSelector = ({ mailboxId, messageId, templates = []
}
if (isForced) {
if (mobileDrawer) {
return (
<MobileToolbarButton
icon={<Icon icon={LockIcon} size={IconSize.MEDIUM} />}
label={t("This signature is forced")}
isDisabled
onClick={() => {}}
/>
);
}
return <Components.FormattingToolbar.Button
className="signature-block-selector signature-block-selector--forced"
icon={<Icon name="lock" size={IconSize.SMALL} />}
@@ -117,30 +148,30 @@ export const SignatureTemplateSelector = ({ mailboxId, messageId, templates = []
secondaryTooltip={t("You cannot modify it.")}
>
<div className="signature-block-selector__content">
<Icon name="lock" size={IconSize.SMALL} />
<Icon icon={LockIcon} size={IconSize.SMALL} />
<p>{forcedTemplate.name}</p>
</div>
</Components.FormattingToolbar.Button>;
}
return (
<Components.FormattingToolbar.Select
key="signatureTemplateSelector"
items={[
{
// Shared between the desktop inline select and the mobile drawer list.
const signatureItems = [
{
text: t("No signatures"),
isSelected: !isSelected,
isDisabled: false,
icon: <Icon name="drive_file_rename_outline" size={IconSize.SMALL} />,
icon: <Icon name="signature" size={IconSize.SMALL} />,
onClick: () => {
editor.removeBlocks(["signature"]);
},
},
...templates.map((template) => ({
},
...templates.map((template) => ({
text: template.name,
isSelected: isSelected === template.id,
isDisabled: template.is_forced,
icon: <Icon name={template.is_forced ? "lock" : "drive_file_rename_outline"} size={IconSize.SMALL} />,
icon: template.is_forced
? <Icon icon={LockIcon} size={IconSize.SMALL} />
: <Icon name="sign" size={IconSize.SMALL} />,
onClick: () => {
const signatureBlock = editor.getBlock('signature');
@@ -183,8 +214,56 @@ export const SignatureTemplateSelector = ({ mailboxId, messageId, templates = []
MessageComposerHelper.insertSignatureBlock(editor, newBlock);
}
}
})),
]}
})),
];
if (mobileDrawer) {
return (
<>
<MobileToolbarButton
icon={<Icon name="signature" size={IconSize.MEDIUM} />}
label={t("Signature")}
isActive={!!isSelected || mobileDrawer.openId === SIGNATURES_DRAWER_ID}
onClick={() => mobileDrawer.open(SIGNATURES_DRAWER_ID)}
/>
{mobileDrawer.openId === SIGNATURES_DRAWER_ID &&
mobileDrawer.slot &&
createPortal(
<Drawer title={t("Signature")} onClose={mobileDrawer.close}>
<div className="drawer-list">
{signatureItems.map((item) => (
<button
type="button"
key={item.text}
className={`drawer-list__item${item.isSelected ? " drawer-list__item--selected" : ""}`}
disabled={item.isDisabled}
aria-pressed={item.isSelected}
onClick={() => {
item.onClick();
mobileDrawer.close();
}}
>
{item.icon}
<span className="drawer-list__item-label">
{item.text}
</span>
{item.isSelected && (
<Icon name="check" size={IconSize.SMALL} />
)}
</button>
))}
</div>
</Drawer>,
mobileDrawer.slot,
)}
</>
);
}
return (
<Components.FormattingToolbar.Select
key="signatureTemplateSelector"
items={signatureItems}
/>
);
}
@@ -0,0 +1,3 @@
export const ToolbarSeparator = () => (
<div className="bn-toolbar-separator" role="separator" />
);
+22 -10
View File
@@ -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 = () => (
<div className="bn-toolbar-separator" role="separator" />
);
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) => {
<FormattingToolbar>
<BlockTypeSelect key={"blockTypeSelect"} items={filteredItems} />
<ImageUploadButton />
<CreateLinkButton key={"createLinkButton"} />
<ColumnLayoutInsertButton />
<ToolbarSeparator key={"separator-1"} />
@@ -73,10 +74,21 @@ export const Toolbar = ({ children }: ToolbarProps) => {
<TextAlignButton textAlignment={"center"} key={"textAlignCenterButton"} />
<TextAlignButton textAlignment={"right"} key={"textAlignRightButton"} />
<ToolbarSeparator key={"separator-4"} />
<CreateLinkButton key={"createLinkButton"} />
{children}
{children && (
<>
<ToolbarSeparator key={"separator-4"} />
{children}
</>
)}
</FormattingToolbar>
)
);
};
export const Toolbar = ({ children }: ToolbarProps) => {
// The platform never changes at runtime, so branching to components with
// different hooks is safe.
if (isNativePlatform()) {
return <MobileToolbar>{children}</MobileToolbar>;
}
return <DesktopToolbar>{children}</DesktopToolbar>;
}
+49 -1
View File
@@ -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
@@ -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<MessageComposerHandle, MessageCo
initialContent: getInitialContent(),
uploadFile,
dictionary: createBlockNoteDictionary(locale, t),
...createNativeLinkOptions(),
...blockNoteOptions,
_tiptapOptions: {
extensions: [SmartTrailingBlock, PasteColorSanitizer],
@@ -1,4 +1,4 @@
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
import { IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
import { Button, Tooltip, useModals } from "@gouvfr-lasuite/cunningham-react";
import { clsx } from "clsx";
import { useEffect, useMemo, useState, useRef, forwardRef, useImperativeHandle } from "react";
@@ -28,6 +28,8 @@ import { useConfig } from "@/features/providers/config";
import { DriveFile } from "./drive-attachment-picker";
import { useAttachments } from "@/features/forms/hooks/use-attachments";
import { MessageComposerHelper } from "@/features/utils/composer-helper";
import { Icon } from "@/features/ui/components/icon";
import { Send, Trash } from "@gouvfr-lasuite/ui-kit/icons";
export type MessageFormMode = "new" | "reply" | "reply_all" | "forward";
@@ -887,7 +889,9 @@ export const MessageForm = forwardRef<MessageFormHandle, MessageFormProps>(({
dropdownOptions={[
...(mode !== 'new' ? [{
label: preferredSendMode === PreferSendMode.SEND_AND_ARCHIVE ? t("Send") : t("Send and archive"),
icon: <Icon name={preferredSendMode === PreferSendMode.SEND_AND_ARCHIVE ? "send" : "send_and_archive"} type={IconType.OUTLINED} />,
icon: preferredSendMode === PreferSendMode.SEND_AND_ARCHIVE
? <Icon icon={Send} />
: <Icon name={"send_and_archive"} type={IconType.OUTLINED} />,
callback: form.handleSubmit(() => handleSubmit({ archive: preferredSendMode !== PreferSendMode.SEND_AND_ARCHIVE })),
showSeparator: true,
}, {
@@ -907,7 +911,7 @@ export const MessageForm = forwardRef<MessageFormHandle, MessageFormProps>(({
variant="tertiary"
onClick={onClose}
aria-label={t("Delete")}
icon={<Icon name="delete" type={IconType.OUTLINED} />}
icon={<Icon icon={Trash} />}
/>
</Tooltip>
)}
@@ -919,7 +923,7 @@ export const MessageForm = forwardRef<MessageFormHandle, MessageFormProps>(({
variant="tertiary"
onClick={() => handleDeleteMessage(draft.id)}
aria-label={t("Delete draft")}
icon={<Icon name="delete" type={IconType.OUTLINED} />}
icon={<Icon icon={Trash} />}
/>
</Tooltip>
)
@@ -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;
@@ -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<HTMLFormElement>(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<HTMLFormElement>) => 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 (
<form className="search__filters" ref={formRef} onSubmit={handleSubmit} onChange={handleChange}>
<form id={id} className="search__filters" ref={formRef} onSubmit={handleSubmit} onChange={handleChange} onReset={handleReset}>
<Input
name="from"
label={t("From")}
@@ -91,27 +100,29 @@ export const SearchFiltersForm = ({ query, onChange }: SearchFiltersFormProps) =
<Label htmlFor="is_starred" id={starredLabelId}>{t("Starred")} :</Label>
<Checkbox id="is_starred" aria-labelledby={starredLabelId} value="true" name="is_starred" checked={Boolean(parsedQuery.is_starred)} />
</div>
<footer className="search__filters-footer">
<Button type="reset" variant="tertiary" onClick={handleReset}>
{t("Reset")}
</Button>
<Button type="submit" variant="primary">
{t("Search")}
</Button>
</footer>
{!hideFooter && (
<footer className="search__filters-footer">
<Button type="reset" variant="tertiary">
{t("Reset")}
</Button>
<Button type="submit" variant="primary">
{t("Search")}
</Button>
</footer>
)}
</form>
);
};
type FolderOptionProps = {
label: string;
icon: string;
icon: IconProps;
}
const FolderOption = ({ label, icon }: FolderOptionProps) => {
return (
<div className="search__filters-folder-option">
<Icon name={icon} />
<Icon {...icon} size={IconSize.SMALL} />
{label}
</div>
);
@@ -41,6 +41,10 @@
display: grid;
place-items: center;
cursor: text;
& > svg {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
}
.search__input {
@@ -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<string>(searchParams.get('search') || '');
const [showFilters, setShowFilters] = useState<boolean>(false);
const { t } = useTranslation();
const { isMobile } = useResponsive();
const searchRef = useRef<HTMLDivElement>(null);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -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 = (
<Modal
isOpen={showFilters}
onClose={() => setShowFilters(false)}
title={t("Search in messages...")}
size={ModalSize.FULL}
stickyFooter
rightActions={
<div className="flex-row flex-justify-end" style={{ paddingBottom: 'var(--c--globals--spacings--sm)' }}>
<Button type="reset" form={SEARCH_FILTERS_FORM_ID} variant="tertiary">
{t("Reset")}
</Button>
<Button type="submit" form={SEARCH_FILTERS_FORM_ID} variant="primary">
{t("Search")}
</Button>
</div>
}
>
{showFilters && (
<SearchFiltersForm
id={SEARCH_FILTERS_FORM_ID}
query={value}
onChange={handleFiltersChange}
autoFocusText
hideFooter
/>
)}
</Modal>
);
if (compact) {
return (
<>
<Button
className="search__trigger"
color="brand"
variant="tertiary"
size="medium"
onClick={() => setShowFilters(true)}
icon={<Icon icon={Zoom} />}
aria-label={t("Search in messages...")}
/>
{filtersModal}
</>
);
}
return (
<div className="search" ref={searchRef}>
<div className="search__container">
<div className="search__input-container">
<label className="search__label" htmlFor="search">
<Icon name="search" style={{ fontSize: '1.125rem' }} />
<Icon icon={Zoom} size={18} />
<span className="c__offscreen">{t("Search in messages...")}</span>
</label>
<input
@@ -91,34 +156,38 @@ export const SearchInput = () => {
value={value}
onChange={handleChange}
onFocus={() => setShowFilters(true)}
onClick={isMobile ? () => setShowFilters(true) : undefined}
onKeyDown={handleKeyPress}
placeholder={t("Search in messages...")}
readOnly={isMobile}
/>
</div>
{ value && (
{value && (
<Button
color="neutral"
variant="tertiary"
onClick={resetInput}
title={t("Reset")}
size="small"
>
<Icon name="close" />
<span className="c__offscreen">{t("Reset")}</span>
</Button>
icon={<Icon icon={XMark} size={IconSize.MEDIUM} />}
aria-label={t("Reset")}
/>
)}
{!isMobile && (
<Button
color="neutral"
variant="tertiary"
onClick={() => setShowFilters(!showFilters)}
title={showFilters ? t("Close filters") : t("Open filters")}
size="small"
>
<Icon name="tune" />
<span className="c__offscreen">{showFilters ? t("Close filters") : t("Open filters")}</span>
</Button>
icon={<Icon icon={Settings} size={IconSize.MEDIUM} />}
aria-label={showFilters ? t("Close filters") : t("Open filters")}
/>
)}
</div>
{showFilters && <SearchFiltersForm query={value} onChange={handleFiltersChange} />}
{isMobile ? filtersModal : (
showFilters && <SearchFiltersForm query={value} onChange={handleFiltersChange} />
)}
</div>
);
}
@@ -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 (
<ModalProvider>
<Modal
isOpen
onClose={() => {}}
aria-label="settings"
size={ModalSize.LARGE}
variant="tab"
sidebarTitle={<div ref={drillDownRef} />}
tabs={[
{ id: "general", label: "General", content: <p /> },
{
id: "imports",
label: "Imports",
content: <p>{IMPORTS_CONTENT}</p>,
},
]}
activeTab={activeTab ?? ""}
onTabChange={() => {}}
/>
</ModalProvider>
);
};
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(<TabModalFixture activeTab="imports" />);
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(<TabModalFixture />);
expect(isShowingContent()).toBe(false);
});
it("does not touch the wide layout, which already shows both panes", async () => {
mockViewportWidth(false);
await render(<TabModalFixture activeTab="imports" />);
expect(isShowingContent()).toBe(false);
});
});
@@ -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<HTMLElement>(".c__modal__tab-layout");
const activeTabButton = layout?.querySelector<HTMLButtonElement>(
'[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],
);
};
@@ -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(
<ToasterItem type="error">
<Icon name="delete" size={IconSize.SMALL} />
<Icon icon={Trash} size={IconSize.SMALL} />
<span>{t('Mailbox {{mailbox}} has been deleted successfully.', { mailbox: email })}</span>
</ToasterItem>
);
@@ -341,7 +343,7 @@ const ActionsRow = ({ onManageAccess, onResetPassword, onResetTotp, onDelete, on
...(onResetTotp ? [{ icon: <Icon name="security" size={IconSize.SMALL} />, label: t('Reset 2FA'), callback: onResetTotp }] : []),
];
const destructive: DropdownMenuItem = {
icon: <Icon name="delete" size={IconSize.SMALL} />, label: t('Delete'), callback: onDelete, variant: 'danger'
icon: <Icon icon={Trash} size={IconSize.SMALL} />, label: t('Delete'), callback: onDelete, variant: 'danger'
};
const options = [
...secondaryActions.map((opt, i) => ({
@@ -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) => {
<Button
color="error"
size="small"
icon={isDeleting ? <Spinner size="sm" /> : <Icon name="delete" size={IconSize.SMALL} />}
icon={isDeleting ? <Spinner size="sm" /> : <Icon icon={Trash} size={IconSize.SMALL} />}
onClick={() => handleDeleteRow(row)}
disabled={isDeleting}
aria-label={t("Delete")}
@@ -66,6 +66,42 @@
width: 100%;
height: 100%;
z-index: 1000;
// The mobile drawer is a modal sheet: unlike the anchored popup, nothing
// ties it visually to its trigger, so the scrim is what signals modality.
&--scrim {
background-color: rgba(0, 0, 0, 0.3);
}
}
// Mobile bottom drawer replacing the anchored popup. The Drawer component
// renders in place; this wrapper pins it to the bottom of the viewport,
// above the scrim.
.labels-widget__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);
}
// Rows sized for touch, unlike the desktop popup's compact list.
.labels-widget__popup__content li {
display: flex;
align-items: center;
min-height: 44px;
}
}
.labels-widget__drawer__search {
padding-inline: var(--c--globals--spacings--base);
}
.labels-widget__popup__content__empty .c__button {
@@ -1,10 +1,11 @@
import { Label, ThreadLabel, TreeLabel, useLabelsList } from "@/features/api/gen";
import { Thread } from "@/features/api/gen/models";
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
import { Spinner, useResponsive } from "@gouvfr-lasuite/ui-kit";
import { Button, Checkbox, Input, Tooltip } from "@gouvfr-lasuite/cunningham-react";
import { RefObject, useEffect, useMemo, useRef, useState } from "react";
import { forwardRef, RefObject, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Drawer } from "@/features/ui/components/drawer";
import { useMailboxContext } from "@/features/providers/mailbox";
import StringHelper from "@/features/utils/string-helper";
import useAbility, { Abilities } from "@/hooks/use-ability";
@@ -12,12 +13,24 @@ import { usePopupPosition } from "@/hooks/use-popup-position";
import { LabelModal } from "@/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-form-modal";
import useDeleteLabel from "@/features/message/use-delete-label";
import useAddLabel from "@/features/message/use-add-label";
import { Icon } from "@/features/ui/components/icon";
import { Plus, TagAdd, Zoom } from "@gouvfr-lasuite/ui-kit/icons";
export type LabelsWidgetHandle = {
open: () => void;
};
type LabelsWidgetProps = {
threadIds: string[];
// Fallback for a deep-linked single thread that is not in `threads.results`
// yet (e.g. filter active). Lets the popup display the right checked state.
initialLabels?: readonly ThreadLabel[];
/**
* Render only the popup/drawer, without the "Add label" trigger button.
* Used on mobile where the widget is opened through the `open()` handle
* (more-options drawer) instead of a dedicated button.
*/
hideTrigger?: boolean;
}
type CreateModalState = {
@@ -46,7 +59,8 @@ const findTreeLabelById = (labels: readonly TreeLabel[], id: string): TreeLabel
return undefined;
};
export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) => {
export const LabelsWidget = forwardRef<LabelsWidgetHandle, LabelsWidgetProps>(
function LabelsWidget({ threadIds, initialLabels, hideTrigger = false }, ref) {
const { t } = useTranslation();
const { selectedMailbox, threads } = useMailboxContext();
const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox);
@@ -58,6 +72,10 @@ export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) =>
const [createModal, setCreateModal] = useState<CreateModalState>({ isOpen: false, initialName: '' });
const anchorRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({
open: () => setIsPopupOpen(true),
}), []);
const { addLabel } = useAddLabel();
const { deleteLabel } = useDeleteLabel();
@@ -90,6 +108,7 @@ export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) =>
if (!canManageLabels) return null;
if (isLoadingLabelsList) {
if (hideTrigger) return null;
return (
<div className="labels-widget" aria-busy={true}>
<Tooltip
@@ -104,7 +123,7 @@ export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) =>
size="nano"
variant="tertiary"
aria-label={t('Add label')}
icon={<Icon type={IconType.OUTLINED} name="new_label" />}
icon={<Icon icon={TagAdd} />}
/>
</Tooltip>
</div>
@@ -113,16 +132,18 @@ export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) =>
return (
<div className="labels-widget" ref={anchorRef}>
<Tooltip content={t('Add label')}>
<Button
onClick={() => setIsPopupOpen(true)}
size="nano"
variant="tertiary"
aria-label={t('Add label')}
disabled={threadIds.length === 0}
icon={<Icon type={IconType.OUTLINED} name="new_label" />}
/>
</Tooltip>
{!hideTrigger && (
<Tooltip content={t('Add label')}>
<Button
onClick={() => setIsPopupOpen(true)}
size="nano"
variant="tertiary"
aria-label={t('Add label')}
disabled={threadIds.length === 0}
icon={<Icon icon={TagAdd} />}
/>
</Tooltip>
)}
{isPopupOpen && (
<LabelsPopup
anchorRef={anchorRef}
@@ -132,8 +153,15 @@ export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) =>
labelCounts={labelCounts}
onAddLabel={handleAddLabel}
onDeleteLabel={handleDeleteLabel}
onCreateLabel={(initialName) => setCreateModal({ isOpen: true, initialName })}
closeOnEsc={!createModal.isOpen}
// 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. The created label is applied by `onSuccess`,
// so the popup has nothing left to show anyway.
onCreateLabel={(initialName) => {
setIsPopupOpen(false);
setCreateModal({ isOpen: true, initialName });
}}
/>
)}
<LabelModal
@@ -144,7 +172,7 @@ export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) =>
/>
</div>
);
};
});
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 = (
<Input
className="labels-widget__popup__search"
type="search"
icon={<Icon icon={Zoom} />}
label={t('Search a label')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
fullWidth
/>
);
const optionsList = (
<ul className="labels-widget__popup__content">
{labelsOptions.map((option) => (
<li key={option.value}>
<Checkbox
checked={option.checked}
indeterminate={option.indeterminate}
onChange={() => handleToggle(option)}
label={option.label}
/>
</li>
))}
<li className="labels-widget__popup__content__empty">
<Button color="brand" variant="primary" onClick={() => onCreateLabel(searchQuery)} fullWidth icon={<Icon icon={Plus} />}>
<span className="labels-widget__popup__content__empty__button-label">
{searchQuery && labelsOptions.length === 0 ? t('Create the label "{{label}}"', { label: searchQuery }) : t('Create a new label')}
</span>
</Button>
</li>
</ul>
);
return createPortal(
<>
<div className="labels-widget__popup__overlay" onClick={onClose}></div>
<div
className="labels-widget__popup"
role="dialog"
aria-modal="true"
aria-label={t('Add labels')}
style={{
position: 'fixed',
top: position.top,
right: position.right,
maxHeight: position.maxHeight,
}}
>
<header className="labels-widget__popup__header">
<h3><Icon type={IconType.OUTLINED} name="new_label" /> {t('Add labels')}</h3>
<Input
className="labels-widget__popup__search"
type="search"
icon={<Icon type={IconType.OUTLINED} name="search" />}
label={t('Search a label')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
fullWidth
/>
</header>
<ul className="labels-widget__popup__content">
{labelsOptions.map((option) => (
<li key={option.value}>
<Checkbox
checked={option.checked}
indeterminate={option.indeterminate}
onChange={() => handleToggle(option)}
label={option.label}
/>
</li>
))}
<li className="labels-widget__popup__content__empty">
<Button color="brand" variant="primary" onClick={() => onCreateLabel(searchQuery)} fullWidth icon={<Icon type={IconType.OUTLINED} name="add" />}>
<span className="labels-widget__popup__content__empty__button-label">
{searchQuery && labelsOptions.length === 0 ? t('Create the label "{{label}}"', { label: searchQuery }) : t('Create a new label')}
</span>
</Button>
</li>
</ul>
</div>
</>,
portalTarget
isMobile ? (
<>
<div
className="labels-widget__popup__overlay labels-widget__popup__overlay--scrim"
onClick={onClose}
></div>
<div className="labels-widget__drawer">
<Drawer title={t('Add labels')} onClose={onClose}>
<div className="labels-widget__drawer__search">{searchInput}</div>
{optionsList}
</Drawer>
</div>
</>
) : (
<>
<div className="labels-widget__popup__overlay" onClick={onClose}></div>
<div
className="labels-widget__popup"
role="dialog"
aria-modal="true"
aria-label={t('Add labels')}
style={{
position: 'fixed',
top: position?.top,
right: position?.right,
maxHeight: position?.maxHeight,
}}
>
<header className="labels-widget__popup__header">
<h3><Icon icon={TagAdd} /> {t('Add labels')}</h3>
{searchInput}
</header>
{optionsList}
</div>
</>
),
document.body
);
};
@@ -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<string | null>(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<number | null>(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<void>((resolve) => window.setTimeout(resolve, MIN_ANIMATION_MS)),
]);
} finally {
setIsRefreshing(false);
}
};
const goToNewMessageForm = (event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
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 = () => {
<div className="mailbox-panel-actions">
<div>
<Button
onClick={goToNewMessageForm}
onClick={goToNewMessage}
href={`/mailbox/${selectedMailbox.id}/new`}
icon={<Icon name="edit_note" type={IconType.OUTLINED} aria-hidden="true" />}
icon={<Icon name="mail-plus" />}
disabled={!canWriteMessages}
>
{t("New message")}
@@ -75,7 +28,7 @@ export const MailboxPanelActions = () => {
<div className="mailbox-panel-actions__extra">
<TransientTooltip
message={feedback}
onHide={() => setFeedback(null)}
onHide={clearFeedback}
placement="bottom"
>
<Button
@@ -91,7 +44,7 @@ export const MailboxPanelActions = () => {
}
variant="tertiary"
aria-label={isRefreshing ? t("Loading…") : t("Refresh")}
onClick={handleRefresh}
onClick={() => void refresh()}
disabled={isRefreshing}
/>
</TransientTooltip>
@@ -56,7 +56,7 @@
padding: 0;
.c__button__icon > span {
font-size: var(--c--globals--font--sizes--h3);
font-size: var(--c--globals--font--sizes--h5);
}
}
@@ -138,7 +138,17 @@ export const LabelModal = ({ isOpen, onClose, label, onSuccess, mailbox }: Label
closeOnClickOutside
>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="label-form">
<form
// The modal can be opened from inside another form (e.g. the
// widget integration form): the DOM portal escapes it but the
// React tree does not, so without this the submit bubbles up and
// triggers the outer form's validation.
onSubmit={(event) => {
event.stopPropagation();
form.handleSubmit(handleSubmit)(event);
}}
className="label-form"
>
<div className="form-field-row">
<RhfInput
name="name"
@@ -3,7 +3,7 @@ import { getThreadsStatsQueryKey, useMailboxContext } from "@/features/providers
import useArchive from "@/features/message/use-archive";
import useDeleteLabel from "@/features/message/use-delete-label";
import useAddLabel from "@/features/message/use-add-label";
import { DropdownMenu, Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit";
import { DropdownMenu, IconSize, IconType } from "@gouvfr-lasuite/ui-kit";
import { Button, useModals } from "@gouvfr-lasuite/cunningham-react";
import clsx from "clsx";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
@@ -19,6 +19,8 @@ import { useFold } from "@/features/providers/fold";
import { SubLabelCreation } from "../label-form-modal";
import { handle } from "@/features/utils/errors";
import ViewHelper from "@/features/utils/view-helper";
import { Edit, More, Plus, Trash } from "@gouvfr-lasuite/ui-kit/icons";
import { Icon } from "@/features/ui/components/icon";
export type LabelTransferData = {
type: 'label';
@@ -376,18 +378,18 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
options={[
{
label: t('Edit'),
icon: <Icon name="edit" />,
icon: <Icon icon={Edit} size={IconSize.SMALL}/>,
callback: () => onEdit(label),
},
{
label: t('Add a sub-label'),
icon: <Icon name="add" />,
icon: <Icon icon={Plus} size={IconSize.SMALL}/>,
callback: () => onEdit({ name: `${label.name}/`, color: label.color }),
showSeparator: true,
},
{
label: t('Delete'),
icon: <Icon name="delete" />,
icon: <Icon icon={Trash} size={IconSize.SMALL} />,
callback: handleDelete,
variant: 'danger'
},
@@ -395,7 +397,7 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
>
<Button
onClick={() => setIsDropdownOpen(true)}
icon={<Icon name="more_horiz" />}
icon={<Icon icon={More} />}
variant="tertiary"
aria-label={t('More options')}
size="nano"
@@ -1,5 +1,5 @@
import { Mailbox, TreeLabel, useLabelsList, useLabelsPartialUpdate } from "@/features/api/gen";
import { Icon, IconSize, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
import { IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
import { Button, useModal, Tooltip } from "@gouvfr-lasuite/cunningham-react";
import { useTranslation } from "react-i18next";
import { LabelModal, SubLabelCreation } from "./components/label-form-modal";
@@ -10,6 +10,8 @@ import { FoldProvider, useFold } from "@/features/providers/fold";
import { useQueryClient } from "@tanstack/react-query";
import { handle } from "@/features/utils/errors";
import clsx from "clsx";
import { Icon } from "@/features/ui/components/icon";
import { Plus } from "@gouvfr-lasuite/ui-kit/icons";
type MailboxLabelsProps = {
mailbox: Mailbox;
@@ -101,7 +103,7 @@ export const MailboxLabelsBase = ({ mailbox }: MailboxLabelsProps) => {
{areAllFolded !== undefined && (
<Tooltip content={areAllFolded ? t('Expand all') : t('Collapse all')} placement="bottom">
<Button
icon={<Icon type={IconType.FILLED} name={areAllFolded ? "unfold_more" : "unfold_less"} size={IconSize.LARGE} />}
icon={<Icon type={IconType.FILLED} name={areAllFolded ? "unfold" : "fold"} />}
color="brand"
variant="tertiary"
size="nano"
@@ -115,7 +117,7 @@ export const MailboxLabelsBase = ({ mailbox }: MailboxLabelsProps) => {
canManageLabels && (
<Tooltip content={t('Create a Label')} placement="bottom">
<Button
icon={<Icon type={IconType.FILLED} name="add" />}
icon={<Icon icon={Plus} />}
variant="primary"
size="nano"
onClick={open}
@@ -6,7 +6,7 @@ import { useUrlSearchParams } from "@/hooks/use-url-search-params"
import { useMemo, useState } from "react"
import { useLayoutContext } from "@/features/layouts/components/layout-context"
import { useTranslation } from "react-i18next"
import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit"
import { IconSize, IconType } from "@gouvfr-lasuite/ui-kit"
import i18n from "@/features/i18n/initI18n";
import useArchive from "@/features/message/use-archive";
import useTrash from "@/features/message/use-trash";
@@ -16,6 +16,8 @@ import ViewHelper from "@/features/utils/view-helper";
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
import { Tooltip } from "@gouvfr-lasuite/cunningham-react"
import { EXPANDED_FOLDERS_KEY } from "@/features/config/constants"
import { Archive, ChevronDown, Edit, Restore, Star, Trash } from "@gouvfr-lasuite/ui-kit/icons"
import { Icon, IconProps } from "@/features/ui/components/icon"
type FolderVisibilityContext = {
mailbox: Mailbox;
@@ -29,7 +31,7 @@ type FolderVisibilityRule = (ctx: FolderVisibilityContext) => boolean;
type Folder = {
id: string;
name: string;
icon: string;
icon: IconProps;
filter?: Record<string, string>;
showStats: boolean;
searchable?: boolean;
@@ -64,7 +66,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "inbox",
name: i18n.t("Inbox"),
icon: "inbox",
icon: { name: "inbox", type: IconType.OUTLINED },
searchable: false,
showStats: true,
filter: {
@@ -74,7 +76,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "unread",
name: i18n.t("Unread"),
icon: "mark_email_unread",
icon: { name: "mail-unread" },
searchable: false,
showStats: true,
filter: {
@@ -85,7 +87,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "starred",
name: i18n.t("Starred"),
icon: "star",
icon: { icon: Star },
searchable: false,
showStats: true,
filter: {
@@ -96,7 +98,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "mentioned",
name: i18n.t("Mentioned"),
icon: "alternate_email",
icon: { name: "at-sign" },
searchable: false,
showStats: true,
isVisible: visibleIfSharedOrHasMention,
@@ -108,7 +110,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "assigned_to_me",
name: i18n.t("Assigned to me"),
icon: "person",
icon: { name: "assign" },
searchable: false,
showStats: true,
isVisible: visibleIfSharedOrNonEmpty,
@@ -120,7 +122,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "unassigned",
name: i18n.t("Unassigned"),
icon: "person_off",
icon: { name: "person_off" },
searchable: false,
showStats: true,
isVisible: visibleIfSharedMailbox,
@@ -134,7 +136,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "drafts",
name: i18n.t("Drafts"),
icon: "mode_edit",
icon: { icon: Edit },
searchable: true,
showStats: true,
filter: {
@@ -144,7 +146,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "outbox",
name: i18n.t("Outbox"),
icon: "schedule_send",
icon: { name: "schedule_send", type: IconType.OUTLINED },
searchable: false,
isVisible: visibleIfNonEmpty,
showStats: true,
@@ -156,7 +158,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "sent",
name: i18n.t("Sent"),
icon: "outbox",
icon: { name: "outbox", type: IconType.OUTLINED },
searchable: true,
showStats: false,
filter: {
@@ -167,7 +169,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "archives",
name: i18n.t("Archives"),
icon: "inventory_2",
icon: { icon: Archive },
searchable: true,
showStats: true,
filter: {
@@ -177,7 +179,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "spam",
name: i18n.t("Spam"),
icon: "report",
icon: { name: "report", type: IconType.OUTLINED },
searchable: true,
showStats: true,
filter: {
@@ -187,7 +189,7 @@ export const MAILBOX_FOLDERS = () => [
{
id: "trash",
name: i18n.t("Trash"),
icon: "delete",
icon: { icon: Trash },
searchable: true,
showStats: false,
filter: {
@@ -205,7 +207,7 @@ export const MAILBOX_FOLDERS = () => [
export const ALL_MESSAGES_FOLDER = () => ({
id: "all_messages" as const,
name: i18n.t("All messages"),
icon: "mark_as_unread",
icon: { name: "mark_as_unread", type: IconType.OUTLINED },
showStats: true,
filter: {
has_messages: "1",
@@ -491,7 +493,7 @@ const FolderItem = ({ folder, isChild, hasChildren, isExpanded, onToggleExpand,
type="info"
actions={[{ label: t('Undo'), onClick: () => markAsTrashed({ threadIds }) }]}
>
<Icon name="restore_from_trash" type={IconType.OUTLINED} />
<Icon icon={Restore} />
<span>{t('{{count}} threads have been restored.', { count: threadIds.length, defaultValue_one: 'The thread has been restored.' })}</span>
</ToasterItem>
);
@@ -547,7 +549,7 @@ const FolderItem = ({ folder, isChild, hasChildren, isExpanded, onToggleExpand,
onDrop={isDroppable ? handleDrop : undefined}
>
<p className="mailbox__item-label">
<Icon name={folder.icon} type={IconType.OUTLINED} aria-hidden="true" size={IconSize.SMALL} />
<Icon {...folder.icon} size={IconSize.SMALL} />
{t(folder.name)}
</p>
<div className="mailbox__item__metadata">
@@ -578,7 +580,7 @@ const FolderItem = ({ folder, isChild, hasChildren, isExpanded, onToggleExpand,
aria-controls={childrenContainerId}
aria-label={chevronLabel}
>
<Icon name="expand_more" type={IconType.OUTLINED} aria-hidden="true" />
<Icon icon={ChevronDown} />
</button>
{link}
</div>
@@ -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 (
<>
<Button
size={size}
size={isMobile ? "small" : "nano"}
onClick={() => modal.open()}
icon={<Icon name="add" />}
icon={<Icon icon={Plus} />}
aria-label={isMobile ? t("New auto-reply") : undefined}
>
{t("New auto-reply")}
{!isMobile && t("New auto-reply")}
</Button>
<ModalComposeMailboxAutoreply
isOpen={modal.isOpen}
@@ -1,4 +1,4 @@
import { Icon, IconSize, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
import { Icon, IconSize, IconType, Spinner, useResponsive } from "@gouvfr-lasuite/ui-kit";
import { Trash } from "@gouvfr-lasuite/ui-kit/icons";
import {
Button,
@@ -86,6 +86,7 @@ export const UserDevicesGrid = () => {
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<string, string> = 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(
<ToasterItem type="error">
<span>{messages[result]}</span>
@@ -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<Channel>] : []),
{
// 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 ? (
<div
className="flex-row flex-justify-end"
style={{ marginBottom: "var(--c--globals--spacings--sm)" }}
style={{ marginBlock: "var(--c--globals--spacings--sm)" }}
>
<Button
variant="secondary"
@@ -13,16 +13,23 @@
// Rendered in the settings tab title slot: the back-link sits above the heading.
.import-new-title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--c--globals--spacings--3xs);
flex-direction: row;
align-items: center;
&__heading {
line-height: 1rem;
}
&__back {
// Compact, secondary — a link back to the list, not a heading-sized CTA.
font-weight: 400;
margin-left: calc(var(--c--globals--spacings--sm) * -1);
}
}
.c__modal__tab-content__header:has(.import-new-title) > .c__modal__tab-content__back {
display: none;
}
.import-form {
display: flex;
flex-direction: column;
@@ -1,5 +1,6 @@
import { Icon } from "@/features/ui/components/icon";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { Icon } from "@gouvfr-lasuite/ui-kit";
import { ChevronLeft } from "@gouvfr-lasuite/ui-kit/icons";
import { useTranslation } from "react-i18next";
/**
@@ -13,15 +14,12 @@ export const ImportNewTitle = ({ onBack }: { onBack: () => void }) => {
<span className="import-new-title">
<Button
className="import-new-title__back"
type="button"
size="small"
color="neutral"
variant="tertiary"
icon={<Icon name="arrow_back" />}
icon={<Icon icon={ChevronLeft} />}
onClick={onBack}
>
{t("Back to imports")}
</Button>
aria-label={t("Back to imports list")}
/>
<span className="import-new-title__heading">{t("Start a new import")}</span>
</span>
);
@@ -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 name="pause" />,
icon: <Icon icon={Pause} size={IconSize.SMALL} />,
callback: () => onPause(row),
});
} else {
options.push({
label: t("Resume polling"),
icon: <Icon name="play_arrow" />,
icon: <Icon icon={Play} size={IconSize.SMALL} />,
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 name="delete" />,
icon: <Icon icon={Trash} size={IconSize.SMALL} />,
callback: () => onCancel(row),
variant: "danger",
});
@@ -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 (
<>
<Button
size={size}
size={isMobile ? "small" : "nano"}
onClick={() => modal.open()}
icon={<Icon name="add" />}
icon={<Icon icon={Plus} />}
aria-label={isMobile ? t("New integration") : undefined}
>
{t("New integration")}
{!isMobile && t("New integration")}
</Button>
<ModalComposeIntegration
isOpen={modal.isOpen}
@@ -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 { 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";
import { ModalComposeTemplate } from "../modal-compose-template";
import { useResponsive } from "@gouvfr-lasuite/ui-kit";
type ComposeTemplateActionProps = {
mailbox: Mailbox;
size?: ButtonProps["size"];
};
export const ComposeTemplateAction = ({ mailbox, size }: ComposeTemplateActionProps) => {
export const ComposeTemplateAction = ({ mailbox }: ComposeTemplateActionProps) => {
const { t } = useTranslation();
const modal = useModal();
const { isMobile } = useResponsive();
return (
<>
<Button
size={size}
size={isMobile ? "small" : "nano"}
onClick={() => modal.open()}
icon={<Icon name="add" />}
icon={<Icon icon={Plus} />}
aria-label={isMobile ? t("New template") : undefined}
>
{t("New template")}
{!isMobile && t("New template")}
</Button>
<ModalComposeTemplate
isOpen={modal.isOpen}
@@ -1,5 +1,5 @@
import { Modal, ModalSize, Button } from "@gouvfr-lasuite/cunningham-react";
import { Icon, IconType, IconSize } from "@gouvfr-lasuite/ui-kit";
import { Icon, IconType, IconSize, useResponsive } from "@gouvfr-lasuite/ui-kit";
import { ArrowLeft } from "@gouvfr-lasuite/ui-kit/icons";
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
@@ -104,6 +104,7 @@ export const ModalComposeIntegration = ({
channel: initialChannel,
onSuccess,
}: ModalComposeIntegrationProps) => {
const { isMobile } = useResponsive();
const { t } = useTranslation();
const config = useConfig();
const [currentChannel, setCurrentChannel] = useState<Channel | undefined>(initialChannel);
@@ -176,7 +177,7 @@ export const ModalComposeIntegration = ({
isOpen={isOpen}
onClose={onClose}
title={getTitle()}
size={ModalSize.LARGE}
size={isMobile ? ModalSize.FULL : ModalSize.LARGE}
>
<div className="modal-compose-integration">
{viewState === "select_type" && (
@@ -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')}
>
<Icon name="close" size={IconSize.SMALL} type={IconType.OUTLINED} />
<Icon icon={XMark} size={IconSize.SMALL} />
</button>
</Badge>
);
@@ -167,7 +169,7 @@ export const TagsSelector = ({ mailbox, selectedTags, onTagsChange }: TagsSelect
e.stopPropagation();
setIsPopupOpen(true);
}}
icon={<Icon name="new_label" type={IconType.OUTLINED} />}
icon={<Icon icon={TagAdd} />}
aria-label={t('Add tags')}
/>
</div>
@@ -182,11 +184,11 @@ export const TagsSelector = ({ mailbox, selectedTags, onTagsChange }: TagsSelect
style={{ position: 'fixed', top: position.top, left: position.left, maxHeight: position.maxHeight }}
>
<header className="labels-widget__popup__header">
<h3><Icon type={IconType.OUTLINED} name="new_label" /> {t('Add tags')}</h3>
<h3><Icon icon={TagAdd} /> {t('Add tags')}</h3>
<Input
className="labels-widget__popup__search"
type="search"
icon={<Icon type={IconType.OUTLINED} name="search" />}
icon={<Icon icon={Zoom} />}
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 type={IconType.OUTLINED} name="add" />}
icon={<Icon icon={Plus} />}
>
<span className="labels-widget__popup__content__empty__button-label">
{searchQuery && labelsOptions.length === 0
@@ -218,19 +227,19 @@ export const TagsSelector = ({ mailbox, selectedTags, onTagsChange }: TagsSelect
: t('Create a new label')}
</span>
</Button>
<LabelModal
isOpen={isOpen}
onClose={close}
mailbox={mailbox}
label={{ display_name: searchQuery }}
onSuccess={handleCreateLabel}
/>
</li>
</ul>
</div>
</>,
document.body
)}
<LabelModal
isOpen={isOpen}
onClose={close}
mailbox={mailbox}
label={{ display_name: searchQuery }}
onSuccess={handleCreateLabel}
/>
</Field>
);
};
@@ -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
<Modal
isOpen={isOpen}
title={autoreply ? t('Edit auto-reply "{{autoreply}}"', { autoreply: autoreply.name }) : t("Create a new auto-reply")}
size={ModalSize.LARGE}
size={isMobile ? ModalSize.FULL : ModalSize.LARGE}
onClose={guardedOnClose}
>
<div className="modal-compose-template">
@@ -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
<Modal
isOpen={isOpen}
title={signature ? t('Edit signature "{{signature}}"', { signature: signature.name }) : t("Create a new signature")}
size={ModalSize.LARGE}
size={isMobile ? ModalSize.FULL : ModalSize.LARGE}
onClose={guardedOnClose}
>
<div className="modal-compose-template">
@@ -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
<Modal
isOpen={isOpen}
title={template ? t('Edit template "{{template}}"', { template: template.name }) : t("Create a new template")}
size={ModalSize.LARGE}
size={isMobile ? ModalSize.FULL : ModalSize.LARGE}
onClose={guardedOnClose}
>
<div className="modal-compose-template">
@@ -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 {
@@ -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={
<Button
size="nano"
icon={<Icon name="add" />}
size={isMobile ? "small" : "nano"}
icon={<Icon icon={UserAdd} />}
onClick={() => setIsAddMemberOpen(true)}
aria-label={isMobile ? t("Add a member") : undefined}
>
{t("Add a member")}
{!isMobile && t("Add a member")}
</Button>
}
/>
@@ -34,7 +34,7 @@ export const MailboxSettingsAutorepliesTab = ({
? t("No auto-reply")
: t("{{count}} auto-reply", { count })
}
action={<ComposeAutoreplyAction mailbox={mailbox} size="nano" />}
action={<ComposeAutoreplyAction mailbox={mailbox} />}
/>
<AutoreplyDataGrid mailbox={mailbox} />
</section>
@@ -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={
<Button
size="nano"
icon={<Icon name="add" />}
size={isMobile ? "small" : "nano"}
icon={<Icon icon={Plus} />}
onClick={() => onViewChange("new")}
aria-label={isMobile ? t("New import") : undefined}
>
{t("New import")}
{!isMobile && t("New import")}
</Button>
}
/>
@@ -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={<div className="mailbox-settings__identity">{sidebarHeader}</div>}
sidebarTitle={
<div className="mailbox-settings__identity" ref={drillDownRef}>
{sidebarHeader}
</div>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={async (tabId) => {
@@ -28,7 +28,7 @@ export const MailboxSettingsIntegrationsTab = ({
? t("No integration")
: t("{{count}} integration", { count })
}
action={<CreateIntegrationAction mailbox={mailbox} size="nano" />}
action={<CreateIntegrationAction mailbox={mailbox} />}
/>
<IntegrationsDataGrid mailbox={mailbox} />
</section>
@@ -34,7 +34,7 @@ export const MailboxSettingsMessageTemplatesTab = ({
? t("No message template")
: t("{{count}} message template", { count })
}
action={<ComposeTemplateAction mailbox={mailbox} size="nano" />}
action={<ComposeTemplateAction mailbox={mailbox} />}
/>
<MessageTemplateDataGrid mailbox={mailbox} />
</section>
@@ -34,7 +34,7 @@ export const MailboxSettingsSignaturesTab = ({
? t("No signatures")
: t("{{count}} signature", { count })
}
action={<ComposeSignatureAction mailbox={mailbox} size="nano" />}
action={<ComposeSignatureAction mailbox={mailbox} />}
/>
<SignatureDataGrid mailbox={mailbox} />
</section>
@@ -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 (
<>
<Button
size={size}
size={isMobile ? "small" : "nano"}
onClick={() => modal.open()}
icon={<Icon name="add" />}
icon={<Icon icon={Plus} />}
aria-label={isMobile ? t("New signature") : undefined}
>
{t("New signature")}
{!isMobile && t("New signature")}
</Button>
<ModalComposeMailboxSignature
isOpen={modal.isOpen}
@@ -6,32 +6,77 @@
grid-template-areas: "left center right";
background-color: var(--c--contextuals--background--surface--secondary);
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
height: var(--header-height);
min-height: var(--header-height);
max-height: var(--header-height);
padding: 0 1.125rem;
// No fixed height: the header sizes to its content + block padding, so the
// flex column can split 100dvh between header and content without a
// hardcoded offset. The block padding (2xs) lands the single-row header at
// ~52px around the 40px logo. The top safe-area inset keeps content below
// the Dynamic Island while the background still spans to the device edge.
padding: calc(var(--safe-area-inset-top) + var(--c--globals--spacings--2xs)) 1.125rem var(--c--globals--spacings--2xs);
// ui-kit pins .c__header to a fixed 52px through all three of height,
// min-height and max-height: undo them so the header sizes to its content.
// Without this the mobile web header cannot grow to fit its second (search)
// row, which then overflows onto the thread list.
height: auto;
min-height: 0;
max-height: none;
&--anonymous {
border-color: transparent;
}
// On the native app the search field is replaced by a trigger in the trailing
// slot, so the header drops the second (search) row and stays a single ~52px
// bar. It floats over the content with no bottom border: its controls carry
// their own surface instead.
//
// Excludes the anonymous (home page) header: it has no panel toggle and no
// search, so this three-zone grid would leave an empty leading column before
// the logo, and its language / lagaufre controls are not app chrome — they
// keep their plain look.
:root.native &:not(#{&}--anonymous) {
grid-template-columns: 40px 1fr 40px;
grid-template-rows: 1fr;
grid-template-areas: "toggle left right";
border-bottom: none;
.c__header__toggle-menu .c__button,
.search__trigger.c__button {
@include control-surface;
}
}
// Mobile (web and native): three zones — the panel toggle, the app icon
// centred on the header, and a trailing slot holding the search trigger on
// native. Both side columns are fixed and equal so the icon stays optically
// centred even when the trailing slot is empty — on the web, where the search
// bar takes the second full-width row instead.
@media screen and (max-width: breakpoint(tablet)) {
grid-template-columns: auto 1fr 1fr;
grid-template-columns: 40px 1fr 40px;
grid-template-rows: 1fr 1fr;
grid-template-areas:
"toggle left left"
"toggle left right"
"center center center";
padding: 0 var(--c--globals--spacings--base);
padding: calc(var(--safe-area-inset-top) + var(--c--globals--spacings--2xs)) var(--c--globals--spacings--base) var(--c--globals--spacings--2xs);
&__left {
justify-content: space-between;
justify-content: center;
}
&__center {
padding-bottom: var(--c--globals--spacings--sm);
padding-bottom: var(--c--globals--spacings--2xs);
}
// The anonymous header has no panel toggle and keeps its own controls
// (language, lagaufre) inline, so it stays a plain two-zone bar.
&--anonymous {
grid-template-columns: 1fr auto;
grid-template-rows: 1fr;
grid-template-areas: "left right";
.c__header__left {
justify-content: flex-start;
}
}
}
&__toggle-menu {
@@ -66,14 +111,6 @@
}
}
// On the native app the header is pinned at the physical top of an
// edge-to-edge webview: keep its background under the status bar / notch but
// push its content below. --header-height grows by the same amount (see
// globals.scss), so the content zone keeps its nominal height (border-box).
.native .c__header {
padding-top: var(--safe-area-inset-top);
}
.user-menu__footer-action {
display: flex;
flex-direction: row;
@@ -1,41 +1,20 @@
import { HeaderProps, Icon, useResponsive } from "@gouvfr-lasuite/ui-kit";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { useTranslation } from "react-i18next";
import { HeaderProps } from "@gouvfr-lasuite/ui-kit";
import { LanguagePicker } from "../language-picker";
import { LagaufreButton } from "@/features/ui/components/lagaufre";
import { isNativePlatform } from "@/features/native/platform";
export const AnonymousHeader = ({
leftIcon,
onTogglePanel,
isPanelOpen,
}: HeaderProps) => {
const { t } = useTranslation();
const { isDesktop } = useResponsive();
return (
<div className="c__header c__header--anonymous">
<div className="c__header__toggle-menu">
<Button
size="medium"
onClick={onTogglePanel}
aria-label={isPanelOpen ? t("Close the menu") : t("Open the menu")}
color="brand"
variant="tertiary"
icon={<Icon name={isPanelOpen ? "close" : "menu"} />}
/>
</div>
<div className="c__header__left">
{leftIcon}
</div>
<div className="c__header__right">
{isDesktop && (
<>
<LanguagePicker />
<LagaufreButton />
</>
)}
<LanguagePicker />
{ !isNativePlatform() && <LagaufreButton />}
</div>
</div>
);
@@ -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 (
<div className="c__header">
@@ -45,22 +53,21 @@ export const AuthenticatedHeader = ({
aria-label={isPanelOpen ? t("Close the menu") : t("Open the menu")}
color="brand"
variant="tertiary"
icon={
<Icon name={isPanelOpen ? "close" : "menu"} />
}
icon={<Icon icon={isPanelOpen ? XMark : LeftPanel} />}
/>
</div>
<div className="c__header__left">
{leftIcon}
</div>
<div className="c__header__center">
{!hideSearch && <SearchInput />}
</div>
{isDesktop && (
<div className="c__header__right">
<HeaderRight />
{showSearchField && (
<div className="c__header__center">
<SearchInput />
</div>
)}
<div className="c__header__right">
{showSearchTrigger && <SearchInput compact />}
{isDesktop && <HeaderRight />}
</div>
</div>
);
};
@@ -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 = () => {
<SurveyButton iconOnly color="brand" variant="tertiary" />
<ApplicationMenu />
{isDesktop && <VerticalSeparator size="24px" withPadding={false} />}
<LagaufreButton />
{!isNativePlatform() && <LagaufreButton />}
</div>
<UserMenu
user={user ? {
@@ -207,6 +214,7 @@ export const HeaderRight = () => {
} : null}
logout={logout}
termOfServiceUrl={themeConfig.terms_of_service_url}
withMobileView={false}
actions={
<div className="user-menu__footer-action">
<LanguagePicker size="small" compact />
@@ -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 (
<>
<DropdownMenu
isOpen={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
options={[
...(canOpenMailboxSettings ? [{
label: t("All settings"),
icon: <Controls size="medium" />,
callback: () => openModal(MODAL_MAILBOX_SETTINGS_ID),
showSeparator: canAccessDomainAdmin && !canImportMessages
}] : []),
...(canImportMessages ? [importMessageOption] : []),
...(canManageNotifications ? [{
label: t("Notifications"),
icon: <Icon name="notifications" style={{ fontSize: 24 }} />,
callback: () => openModal(MODAL_NOTIFICATIONS_ID),
showSeparator: canAccessDomainAdmin,
}] : []),
...(canAccessDomainAdmin ? [{
label: t("Domain admin"),
icon: <Icon name="domain" style={{ fontSize: 24 }} />,
callback: () => navigate({ to: "/domain" }),
}] : []),
]}
<DropdownMenu
isOpen={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
options={[
...(canOpenMailboxSettings ? [{
label: t("All settings"),
icon: <Controls size="medium" />,
callback: () => openModal(MODAL_MAILBOX_SETTINGS_ID),
showSeparator: canAccessDomainAdmin && !canImportMessages
}] : []),
...(canImportMessages ? [importMessageOption] : []),
...(canManageNotifications ? [{
label: t("Notifications"),
icon: <Icon name="notifications" />,
callback: () => openModal(MODAL_NOTIFICATIONS_ID),
showSeparator: canAccessDomainAdmin,
}] : []),
...(canAccessDomainAdmin ? [{
label: t("Domain admin"),
icon: <Icon name="domain" />,
callback: () => navigate({ to: "/domain" }),
}] : []),
]}
>
<Button
<Button
onClick={() => setIsDropdownOpen(true)}
icon={<GearRounded />}
aria-label={t("More options")}
color="brand"
variant="tertiary"
/>
/>
</DropdownMenu>
</>
)
@@ -14,6 +14,7 @@ import { LayoutProvider, useLayoutDragContext } from "@/features/layouts/compone
import { AttachmentPreviewModal } from "@/features/layouts/components/thread-view/components/attachment-preview-modal";
import { Link } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { useResponsive } from "@gouvfr-lasuite/ui-kit";
export const MainLayout = ({ children }: PropsWithChildren) => {
return (
@@ -42,6 +43,11 @@ const MainLayoutContent = ({ children }: PropsWithChildren<{ simple?: boolean }>
const hasNoMailbox = queryStates.mailboxes.status === 'success' && mailboxes!.length === 0;
const { theme, variant } = useTheme();
const { isLeftPanelOpen, setIsLeftPanelOpen, isDragging } = useLayoutDragContext();
const { isDesktop } = useResponsive();
// The mobile header centres the app icon between the panel toggle and the
// search trigger: the full wordmark would not fit between them (same rule
// as the home page header).
const headerIconName = isDesktop ? `app-logo-${variant}` : `app-icon-${variant}`;
useUnreadBadge();
@@ -51,7 +57,7 @@ const MainLayoutContent = ({ children }: PropsWithChildren<{ simple?: boolean }>
isLeftPanelOpen={isLeftPanelOpen}
setIsLeftPanelOpen={setIsLeftPanelOpen}
leftPanelContent={<LeftPanel hasNoMailbox={hasNoMailbox} />}
icon={<Link to="/"><img src={`/images/${theme}/app-logo-${variant}.svg`} alt={t("logo")} height={40} /></Link>}
icon={<Link to="/"><img src={`/images/${theme}/${headerIconName}.svg`} alt={t("logo")} height={40} /></Link>}
hideLeftPanelOnDesktop={hasNoMailbox}
isDragging={isDragging}
>
@@ -12,30 +12,41 @@
}
}
// The header is an in-flow flex item (not fixed): the flex column splits the
// 100dvh between the header and the content, so when the keyboard shrinks the
// viewport the content shrinks with it instead of overflowing behind a fixed
// header. position:relative keeps its stacking context above the content.
.c__main-layout__header {
position: fixed;
top: 0;
left: 0;
right: 0;
flex-shrink: 0;
position: relative;
z-index: 1000;
}
.c__main-layout__content {
width: 100%;
height: 100%;
flex: 1;
min-height: 0;
display: flex;
margin-top: var(--header-height);
overflow: hidden;
// Containing block for the mobile left-panel drawer, which is absolutely
// positioned to fill this area (already below the in-flow header).
position: relative;
margin-top: 0;
.c__left-panel {
border-right: 0;
// ui-kit sizes the panel off the viewport assuming a 52px header
// (calc(100dvh - 52px)), but our in-flow header is taller, so the panel
// exceeded its slot and made it slightly scrollable. The flex layout
// already gives the slot the exact remaining height: just fill it.
height: 100%;
}
&__center {
display: flex;
&__children {
height: calc(100dvh - var(--header-height));
height: 100%;
width: 100%;
flex: 1;
overflow-y: auto;
@@ -1,5 +1,19 @@
// ui-kit renders the mobile drawer as a position:fixed overlay sized off the
// viewport (calc(100dvh - 52px)), which forced us to know the header height.
// We re-anchor it as an absolute overlay filling .c__main-layout__content —
// that area already starts below the in-flow header, so no header offset is
// needed. Slides in from the left; ui-kit's `.open` resets the transform.
.c__left-panel__mobile {
height: calc(100% - var(--header-height));
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 100%;
height: auto;
transform: translateX(-100%);
// Sit above the floating bottom bars (z-index 1000) so the open drawer
// covers the search/compose bar and the thread toolbar.
z-index: 1001;
}
.left-panel {
@@ -0,0 +1,22 @@
.mobile-bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
gap: var(--c--globals--spacings--sm);
padding: var(--c--globals--spacings--sm);
// Fold the home-indicator inset into the bottom padding so the controls
// stay clear of the system gesture bar.
padding-bottom: calc(var(--c--globals--spacings--sm) + var(--safe-area-inset-bottom));
// The bar itself is just a positioning layer: clicks fall through the gaps
// to the content behind, only the floating controls are interactive.
pointer-events: none;
background: transparent;
> * {
pointer-events: auto;
}
}
@@ -0,0 +1,30 @@
import { PropsWithChildren } from "react";
import { createPortal } from "react-dom";
type MobileBottomBarProps = PropsWithChildren<{
/** Extra class on the inner bar, to vary layout per usage. */
className?: string;
}>;
/**
* Floating bar pinned to the bottom of the viewport on the native app.
*
* Rendered through a portal to `#root` so it stays viewport-fixed regardless of
* transformed ancestors (resizable panels, sliding left panel) while remaining
* inside the same stacking context as the rest of the app. `#root` carries
* `isolation: isolate` (reset.scss), so portaling to `document.body` instead
* would lift the bar out of that context and paint it above everything — even
* the open left-panel drawer (z-index 1001), which is nested under `#root` and
* is meant to cover this bar (z-index 1000). `#root` has no transform, so
* `position: fixed` still resolves against the viewport.
*/
export const MobileBottomBar = ({ children, className }: MobileBottomBarProps) => {
return createPortal(
<div className={`mobile-bottom-bar${className ? ` ${className}` : ""}`}>
{children}
</div>,
document.getElementById("root") ?? document.body,
);
};
export default MobileBottomBar;
@@ -0,0 +1,28 @@
@use "../../../../../styles/utils" as *;
// Two floating controls only: the quick filter hugs the left edge, the compose
// button the right one — nothing in between to catch a thumb by mistake.
.mobile-filter-compose-bar {
justify-content: space-between;
.thread-panel__filter-trigger {
display: inline-flex;
flex: 0 0 auto;
// The shared filter button is sized for a dense desktop toolbar: bring it
// up to the 40px touch box the compose button already uses.
.c__button {
width: 40px;
height: 40px;
@include control-surface;
}
}
// Primary action: keeps its brand background, borrows the shared shape.
&__compose.c__button {
flex: 0 0 auto;
@include control-shape;
}
}
@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { ThreadPanelFilter } from "@/features/layouts/components/thread-panel/components/thread-panel-filter";
import { useComposeMessage } from "@/features/message/use-compose-message";
import { isNativePlatform } from "@/features/native/platform";
import { MobileBottomBar } from "../bottom-bar";
import { Icon } from "@/features/ui/components/icon";
/**
* Native-only bottom bar for the thread-list view: the quick filter on the left,
* a thumb-reachable compose button on the right. Search is not here — it opens
* from the header trigger.
*/
export const MobileFilterComposeBar = () => {
const { t } = useTranslation();
const { canWriteMessages, goToNewMessage, selectedMailbox } = useComposeMessage();
if (!isNativePlatform() || !selectedMailbox) return null;
return (
<MobileBottomBar className="mobile-filter-compose-bar">
<ThreadPanelFilter />
<Button
className="mobile-filter-compose-bar__compose"
onClick={goToNewMessage}
href={`/mailbox/${selectedMailbox.id}/new`}
icon={<Icon name="mail-plus" />}
disabled={!canWriteMessages}
aria-label={t("New message")}
/>
</MobileBottomBar>
);
};
export default MobileFilterComposeBar;
@@ -0,0 +1,31 @@
@use "../../../../../styles/utils" as *;
// Fills the thread view's sticky row on native: back button pinned left,
// assignment widget pinned right. The row itself lets clicks fall through
// (see .thread-view__sticky-container), only the controls are interactive.
.mobile-thread-header {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
pointer-events: none;
> * {
pointer-events: auto;
}
&__back.c__button,
.assignees-widget.c__button {
@include control-surface;
width: 40px;
height: 40px;
}
// The avatar group can be wider than a single icon button: keep the
// control's height but let it grow horizontally.
.assignees-widget.c__button {
width: auto;
min-width: 40px;
}
}
@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { ChevronLeft } from "@gouvfr-lasuite/ui-kit/icons";
import { Icon } from "@/features/ui/components/icon";
import { useMailboxContext } from "@/features/providers/mailbox";
import { AssigneesWidget } from "@/features/layouts/components/thread-view/components/assignees-widget";
type MobileThreadHeaderProps = {
/** Opens the thread accesses modal (read-only assignees path). */
onOpenAccesses: () => void;
};
/**
* Native-only replacement for the desktop ThreadActionBar inside the thread
* view's sticky row: a back button on the left (the conversation fills the
* whole screen, so "back" replaces the desktop "close") and the assignment
* widget on the right. Every other thread action lives in the bottom toolbar
* and its more-options drawer.
*/
export const MobileThreadHeader = ({ onOpenAccesses }: MobileThreadHeaderProps) => {
const { t } = useTranslation();
const { unselectThread } = useMailboxContext();
return (
<div className="mobile-thread-header">
<Button
className="mobile-thread-header__back"
color="neutral"
variant="tertiary"
icon={<Icon icon={ChevronLeft} />}
onClick={unselectThread}
aria-label={t("Back")}
/>
<AssigneesWidget onClick={onOpenAccesses} />
</div>
);
};
export default MobileThreadHeader;
@@ -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);
}
@@ -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<LabelsWidgetHandle>(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 (
<>
<MobileBottomBar className="mobile-thread-toolbar">
<div className="mobile-thread-toolbar__group">
{removeAction && (
<Button
variant="tertiary"
onClick={removeAction.onSelect}
icon={<Icon {...removeAction.icon} />}
aria-label={removeAction.label}
/>
)}
{canArchive && (
<Button
variant="tertiary"
onClick={handleArchive}
icon={isArchived ? <Icon name="unarchive" type={IconType.OUTLINED} /> : <Icon icon={Archive} />}
aria-label={isArchived ? t("Unarchive") : t("Archive")}
/>
)}
<Button
variant="tertiary"
onClick={() => setIsDrawerOpen(true)}
icon={<Icon name="more_horiz" type={IconType.OUTLINED} />}
aria-label={t("More options")}
aria-haspopup="dialog"
aria-expanded={isDrawerOpen}
/>
</div>
{canReply && (
<Button
className="mobile-thread-toolbar__reply"
color="brand"
variant="primary"
onClick={() => requestReply(quickReplyMode!)}
icon={<Icon name={quickReplyMode === "reply_all" ? "reply-all" : "reply"} type={IconType.OUTLINED} />}
aria-label={quickReplyMode === "reply_all" ? t("Reply all") : t("Reply")}
/>
)}
</MobileBottomBar>
<LabelsWidget
ref={labelsWidgetRef}
threadIds={[thread.id]}
initialLabels={thread.labels}
hideTrigger
/>
{isDrawerOpen && createPortal(
<>
<div
className="mobile-thread-toolbar__drawer-overlay"
onClick={() => setIsDrawerOpen(false)}
/>
<div className="mobile-thread-toolbar__drawer">
<Drawer title={t("More options")} onClose={() => setIsDrawerOpen(false)}>
<div className="drawer-list">
{drawerActions.map((action) => (
<Fragment key={action.key}>
{action.separatorBefore && (
<hr className="mobile-thread-toolbar__drawer-separator" />
)}
<button
type="button"
className="drawer-list__item"
onClick={() => {
setIsDrawerOpen(false);
action.onSelect();
}}
>
<Icon {...action.icon} />
<span className="drawer-list__item-label">{action.label}</span>
</button>
</Fragment>
))}
</div>
</Drawer>
</div>
</>,
document.body,
)}
</>
);
};
export default MobileThreadToolbar;
@@ -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 {
@@ -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 {
@@ -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<HTMLAnchorElement>) => {
// ``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<HTMLDivElement>) => {
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 <a>, 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLAnchorElement>) => {
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
// 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
// `<a>`, 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 (
<>
<Link
to="/mailbox/$mailboxId/thread/$threadId"
params={{ mailboxId: params?.mailboxId ?? '', threadId: thread.id }}
search={true}
<div
className={clsx(
'thread-item',
{
@@ -160,37 +249,64 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClickCapture={handleItemClickCapture}
onClick={handleItemClick}
onFocus={onFocusItem}
tabIndex={tabIndex}
ref={itemRef}
role="option"
aria-selected={isSelected}
{...longPressHandlers}
onTouchStart={(e) => {
// 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}
>
<div>
{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.
<span aria-hidden="true" className="thread-item__checkbox-wrapper">
<Checkbox
checked={isSelected}
tabIndex={-1}
className="thread-item__checkbox"
/>
</span>
)}
<div className="thread-item__aside">
<div className="thread-item__read-indicator" />
{/* 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. */}
<div className="thread-item__identity-slot">
{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.
<span aria-hidden="true" className="thread-item__checkbox-wrapper">
<Checkbox
checked={isSelected}
tabIndex={-1}
className="thread-item__checkbox"
/>
</span>
) : lastSenderName && (
// Decorative: the sender is already announced as part of
// the option's accessible name.
<span aria-hidden="true" className="thread-item__avatar">
<UserAvatar fullName={lastSenderName} size="medium" />
</span>
)}
</div>
</div>
<div>
<div className="thread-item__row">
<div className="thread-item__column">
<div className="thread-item__column" id={sendersId}>
{thread.sender_names && thread.sender_names.length > 0 && (
<ThreadItemSenders senders={thread.sender_names} />
)}
@@ -203,7 +319,7 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select
</span>
}
</div>
<div className="thread-item__column thread-item__column--metadata">
<div className="thread-item__column thread-item__column--metadata" id={dateId}>
{(threadDate || thread.messaged_at) && (
<span className="thread-item__date">
{DateHelper.formatDate((threadDate || thread.messaged_at)!, i18n.resolvedLanguage)}
@@ -213,11 +329,23 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select
</div>
<div className="thread-item__row thread-item__row--subject">
<div className="thread-item__column">
<p className="thread-item__subject">
{thread.subject || t('No subject')}
</p>
<Link
to="/mailbox/$mailboxId/thread/$threadId"
params={{ mailboxId: params?.mailboxId ?? '', threadId: thread.id }}
search={true}
className="thread-item__link"
aria-labelledby={`${sendersId} ${subjectId}`}
aria-describedby={`${dateId} ${badgesId} ${labelsId}`}
onFocus={onFocusItem}
tabIndex={tabIndex}
ref={itemRef}
role="option"
aria-selected={isSelected}
>
<p className="thread-item__subject" id={subjectId}>{thread.subject || t('No subject')}</p>
</Link>
</div>
<div className="thread-item__column thread-item__column--badges">
<div className="thread-item__column thread-item__column--badges" id={badgesId}>
{thread.has_draft && (
<Badge aria-label={t('Draft')} title={t('Draft')} color="neutral" variant="tertiary" compact>
<Icon
@@ -235,27 +363,9 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select
color="neutral"
variant="tertiary"
compact>
<Icon
name="attachment"
size={IconSize.SMALL}
aria-hidden="true"
/>
<AttachFile size={IconSize.SMALL} aria-hidden="true" />
</Badge>
) : null}
{thread.has_starred && (
<Badge
aria-label={t('Starred')}
title={t('Starred')}
color="yellow"
variant="tertiary"
compact>
<Icon
name="star"
size={IconSize.SMALL}
aria-hidden="true"
/>
</Badge>
)}
{thread.has_unread_mention && (
<Badge
aria-label={t('Unread mention')}
@@ -320,6 +430,41 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select
</span>
</Tooltip>
)}
{canToggleStar ? (
thread.has_starred ? (
<Button
aria-label={t('Unstar this thread')}
title={t('Unstar this thread')}
color="warning"
variant="tertiary"
size="nano"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markAsUnstarred({ threadIds: [thread.id] });
}}
icon={<StarFilled size={IconSize.SMALL} aria-hidden="true" />}
/>
) : (
<Button
aria-label={t('Star this thread')}
title={t('Star this thread')}
color="neutral"
variant="tertiary"
size="nano"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markAsStarred({ threadIds: [thread.id] });
}}
icon={<Star size={IconSize.SMALL} aria-hidden="true" />}
/>
)
) : thread.has_starred && (
<Badge aria-label={t('Starred')} title={t('Starred')} color="warning" variant="tertiary" compact>
<StarFilled size={IconSize.SMALL} aria-hidden="true" />
</Badge>
)}
</div>
</div>
{showSnippet && (
@@ -337,7 +482,7 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select
)}
</div>
</div>
</Link>
</div>
{(isDragging || isExiting) && dragPreviewContainer.current && createPortal(
<ThreadDragPreview
count={dragCount}
@@ -2,6 +2,7 @@ import clsx from "clsx";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLayoutDragContext } from "@/features/layouts/components/layout-context";
import { isNativePlatform } from "@/features/native/platform";
type ThreadDragPreviewProps = {
count: number;
@@ -19,6 +20,7 @@ export const ThreadDragPreview = ({ count, exiting, onExitEnd }: ThreadDragPrevi
const { t } = useTranslation();
const { dragAction } = useLayoutDragContext();
const ref = useRef<HTMLSpanElement>(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);
@@ -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 (
<div
className={clsx("thread-item-swipe", {
"thread-item-swipe--disabled": !enabled,
"thread-item-swipe--swiping": isSwiping,
"thread-item-swipe--open": openSide !== null,
})}
ref={containerRef}
onClickCapture={handleClickCapture}
>
{showPanels && (
<>
<div
className="thread-item-swipe__panel thread-item-swipe__panel--start"
aria-hidden="true"
>
<button
type="button"
tabIndex={-1}
className="thread-item-swipe__action thread-item-swipe__action--read"
onClick={() => runAction(toggleRead)}
title={readTitle}
>
<Icon
name={hasUnread ? "drafts" : "mark_email_unread"}
type={IconType.OUTLINED}
/>
<span className="thread-item-swipe__action-label">{readLabel}</span>
</button>
</div>
{endWidth > 0 && (
<div
className="thread-item-swipe__panel thread-item-swipe__panel--end"
aria-hidden="true"
>
{canArchive && (
<button
type="button"
tabIndex={-1}
className="thread-item-swipe__action thread-item-swipe__action--archive"
onClick={() =>
runAction(() => actions.setArchived(thread, !isArchivedView))
}
title={archiveLabel}
>
<Icon
name={isArchivedView ? "unarchive" : "archive"}
type={IconType.OUTLINED}
/>
<span className="thread-item-swipe__action-label">
{archiveLabel}
</span>
</button>
)}
{canTrash && (
<button
type="button"
tabIndex={-1}
className="thread-item-swipe__action thread-item-swipe__action--trash"
onClick={() => runAction(trashAction)}
title={trashTitle}
>
<Icon name={trashIcon} type={IconType.OUTLINED} />
<span className="thread-item-swipe__action-label">
{trashLabel}
</span>
</button>
)}
</div>
)}
</>
)}
<div className="thread-item-swipe__content" ref={contentRef}>
{children}
</div>
</div>
);
};
export default ThreadItemSwipe;
@@ -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 }) => <span data-icon={name ?? "svg"} />,
}));
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<HTMLElement>("[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<HTMLElement>(
"[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 (
<CunninghamProvider>
<ContextMenuProvider>
<ThreadPanelFilter key={key} />
</ContextMenuProvider>
</CunninghamProvider>
);
};
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(<RemountingHost />);
});
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(<RemountingHost />);
});
openMenu();
clickItem("Starred");
clickItem("Unread");
expect(checkedLabels()).toEqual(["Starred"]);
clickItem("Starred");
expect(checkedLabels()).toEqual(["Unread"]);
expect(storedFilters()).toEqual(DEFAULT_SELECTED_FILTERS);
});
});
@@ -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<FilterType[]>(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<LongPressPosition>({ x: 0, y: 0 });
const isDisabled = !threads?.results.length && !hasActiveFilters;
const filterLabels: Record<FilterType, string> = useMemo(
@@ -52,7 +56,34 @@ export const ThreadPanelFilter = () => {
[t],
);
const buildMenuItems = (selection: FilterType[]): MenuItem[] =>
THREAD_PANEL_FILTER_PARAMS.map((type) => ({
label: filterLabels[type],
icon: (
<Icon
name={selection.includes(type) ? "check_box" : "check_box_outline_blank"}
/>
),
// 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 (
<ContextMenu
options={THREAD_PANEL_FILTER_PARAMS.map((type) => ({
label: filterLabels[type],
icon: (
<Icon
name={selectedFilters.includes(type) ? "check_box" : "check_box_outline_blank"}
type={IconType.OUTLINED}
/>
),
callback: () => handleSelectFilter(type),
}))}
>
useEffect(() => {
selectFilterRef.current = handleSelectFilter;
});
const trigger = (
<Tooltip
placement="right"
content={getTooltipContent()}
className={isDisabled ? "hidden" : ""}
placement="right"
content={getTooltipContent()}
className={isDisabled ? "hidden" : ""}
>
<Button
onClick={handleToggleClick}
disabled={isDisabled}
icon={hasActiveFilters ? <Icon name="filter-notification" size={22} /> : <Icon icon={Filter} size={22} />}
variant="tertiary"
size="small"
aria-label={t("Filter threads")}
/>
</Tooltip>
);
// Touch devices have no right-click/double-tap to summon the context menu, so
// on the native app a long press opens it imperatively. On desktop the menu
// stays wired to the ContextMenu wrapper (right-click / keyboard).
if (isNative) {
return (
<span className="thread-panel__filter-trigger" {...longPressHandlers}>
{trigger}
</span>
);
}
return (
<ContextMenu options={filterMenuItems}>
{/* The wrapper opens the menu at the pointer, but keeps that position to
itself; mirroring it here is what lets a toggle reopen in place. */}
<span
className="thread-panel__filter-trigger"
onContextMenu={(e) => {
menuPositionRef.current = { x: e.clientX, y: e.clientY };
}}
>
<Button
onClick={handleToggleClick}
disabled={isDisabled}
icon={<Icon name="filter_list" type={IconType.OUTLINED} />}
variant={hasActiveFilters ? "secondary" : "tertiary"}
size="medium"
aria-label={t("Filter threads")}
/>
</Tooltip>
{trigger}
</span>
</ContextMenu>
);
};
@@ -1,11 +1,12 @@
import clsx from "clsx";
import { useUrlSearchParams } from "@/hooks/use-url-search-params";
import { useCurrentFolderName } from "@/hooks/use-current-folder-name";
import { useMailboxContext } from "@/features/providers/mailbox";
import { useTranslation } from "react-i18next";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button, Tooltip, Checkbox } from "@gouvfr-lasuite/cunningham-react";
import useRead from "@/features/message/use-read";
import { DropdownMenu, Icon, IconType, VerticalSeparator } from "@gouvfr-lasuite/ui-kit";
import { DropdownMenu, IconSize, IconType, VerticalSeparator, useResponsive } from "@gouvfr-lasuite/ui-kit";
import ViewHelper from "@/features/utils/view-helper";
import useArchive from "@/features/message/use-archive";
import useSpam from "@/features/message/use-spam";
@@ -18,6 +19,9 @@ import { useThreadPanelFilters } from "../hooks/use-thread-panel-filters";
import { SelectionReadStatus, SelectionStarredStatus } from "@/features/providers/thread-selection";
import { LabelsWidget } from "@/features/layouts/components/labels-widget";
import useAbility, { Abilities } from "@/hooks/use-ability";
import { isNativePlatform } from "@/features/native/platform";
import { Archive, More, MoreVertical, Restore, Star, StarFilled, TodoList, Trash } from "@gouvfr-lasuite/ui-kit/icons";
import { Icon, IconProps } from "@/features/ui/components/icon";
type ThreadPanelTitleProps = {
selectedThreadIds: Set<string>;
@@ -27,13 +31,21 @@ type ThreadPanelTitleProps = {
selectionReadStatus: SelectionReadStatus;
selectionStarredStatus: SelectionStarredStatus;
onSelectAll: () => void;
onDeselectAll: () => void;
onClearSelection: () => void;
onEnableSelectionMode: () => void;
onDisableSelectionMode: () => void;
isRefreshing?: boolean;
refreshFeedback?: string | null;
onClearRefreshFeedback?: () => void;
}
const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, isSelectionMode, selectionReadStatus, selectionStarredStatus, onSelectAll, onClearSelection, onEnableSelectionMode, onDisableSelectionMode }: ThreadPanelTitleProps) => {
/** How long the pull-to-refresh feedback replaces the message count. */
const REFRESH_FEEDBACK_DURATION_MS = 4000;
const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, isSelectionMode, selectionReadStatus, selectionStarredStatus, onSelectAll, onDeselectAll, onClearSelection, onEnableSelectionMode, onDisableSelectionMode, isRefreshing, refreshFeedback, onClearRefreshFeedback }: ThreadPanelTitleProps) => {
const { t } = useTranslation();
const { isDesktop, isMobile } = useResponsive();
const { markAsReadAt } = useRead();
const { markAsArchived, markAsUnarchived } = useArchive();
const { markAsTrashed, markAsUntrashed } = useTrash();
@@ -43,7 +55,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const searchParams = useUrlSearchParams();
const isSearch = searchParams.has('search');
const { threads, selectedMailbox, unselectThread } = useMailboxContext();
const { mailboxes, threads, selectedMailbox, unselectThread } = useMailboxContext();
const isTrashedView = ViewHelper.isTrashedView();
const isSpamView = ViewHelper.isSpamView();
const isArchivedView = ViewHelper.isArchivedView();
@@ -60,11 +72,36 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
const title = useCurrentFolderName() ?? t('Messages');
// Touch has no Escape key and no visible entry point out of selection
// mode other than the overflow menu, so the mobile header swaps the
// folder title and message count for a selection bar carrying an
// explicit exit button.
const showSelectionBar = (isMobile || isNativePlatform()) && isSelectionMode;
// Touch turns the actions into a floating control: bigger targets inside
// the box the rest of the app chrome uses. The native app gets it outside
// selection mode too — its folder row has no filter button (that lives in
// the bottom bar), so the actions have the room, and both views end up
// with the same bar.
const hasFloatingActions = showSelectionBar || isNativePlatform();
// Nano (24px) is a mouse affordance: touch needs a target a fingertip can
// hit. Small (32px) rather than medium (48px) because the selection row
// also carries the select-all checkbox and the exit button — six 48px
// actions would not fit a 375px screen.
const actionButtonSize = hasFloatingActions ? 'small' : 'nano';
const handleSelectAllToggle = () => {
if (isAllSelected) {
onClearSelection();
} else {
if (!isAllSelected) {
onSelectAll();
} else if (showSelectionBar) {
// On touch the checkbox is a pure select/deselect toggle: leaving
// selection mode is the exit button's job, and dropping out of it
// here would strand the user with a long press as the only way
// back in.
onDeselectAll();
} else {
onClearSelection();
}
};
@@ -80,20 +117,21 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
const mainReadTooltip = selectionReadStatus === SelectionReadStatus.READ ? markAllUnreadLabel : markAllTooltip;
const spamLabel = isSpamView ? t('Remove spam report') : t('Report as spam');
const spamIconName = isSpamView ? 'report_off' : 'report';
const spamIconProps:IconProps = {name: isSpamView ? 'report_off' : 'report', type: IconType.OUTLINED};
const spamMutation = isSpamView ? markAsNotSpam : markAsSpam;
const archiveLabel = isArchivedView ? t('Unarchive') : t('Archive');
const archiveIconName = isArchivedView ? 'unarchive' : 'archive';
const archiveIconProps: IconProps = isArchivedView ? {name: 'unarchive', type: IconType.OUTLINED} : {icon: Archive};
const archiveMutation = isArchivedView ? markAsUnarchived : markAsArchived;
const trashLabel = isTrashedView ? t('Undelete') : t('Delete');
const trashIconName = isTrashedView ? 'restore_from_trash' : 'delete';
const trashIconProps: IconProps = { icon: isTrashedView ? Restore : Trash };
const trashMutation = isTrashedView ? markAsUntrashed : markAsTrashed;
const starLabel = t('Star');
const unstarLabel = t('Unstar');
const canStarSelection = !isSpamView && !isTrashedView;
const canArchive = canEditSelection && !isSpamView && !isTrashedView && !isDraftsView;
const canReportSpam = canEditSelection && !isTrashedView && !isSentView && !isDraftsView;
const canTrash = canEditSelection && !isDraftsView;
@@ -159,221 +197,309 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
}
}, [activeFilters, isSearch, threads?.count, t]);
return (
<header className="thread-panel__header">
<div className="thread-panel__header--title-row">
<h2 className="thread-panel__header--title">{title}</h2>
<ThreadPanelFilter />
</div>
<div className="thread-panel__header--details">
{(isSelectionMode || isSomeSelected) && (
<Checkbox
checked={isAllSelected}
indeterminate={isSomeSelected && !isAllSelected}
onChange={handleSelectAllToggle}
aria-label={isAllSelected ? t('Deselect all threads') : t('Select all threads')}
className="thread-panel__header--checkbox"
/>
)}
<p className="thread-panel__header--count" title={countLabel}>
{countLabel}
</p>
<div className="thread-panel__bar">
<Tooltip content={mainReadTooltip}>
<Button
onClick={() => {
// Close the open thread before firing the mutation. Waiting for
// onSuccess would let the visibility observer re-observe the
// newly-unread messages and debounce a mark-as-read that silently
// reverts the action.
unselectThread();
onClearSelection();
markAsReadAt({
threadIds: threadIdsToMark,
readAt: selectionReadStatus === SelectionReadStatus.READ ? null : new Date().toISOString(),
});
}}
icon={<Icon name={selectionReadStatus === SelectionReadStatus.READ ? 'mark_email_unread' : 'drafts'} type={IconType.OUTLINED} />}
variant="tertiary"
size="nano"
aria-label={mainReadTooltip}
/>
</Tooltip>
{isSelectionMode && (
<>
{hasSelectionActions && <VerticalSeparator withPadding={false} />}
{canArchive && (
<Tooltip content={archiveLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
archiveMutation({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
disabled={selectedThreadIds.size === 0}
icon={<Icon name={archiveIconName} type={IconType.OUTLINED} />}
variant="tertiary"
size="nano"
aria-label={archiveLabel}
/>
</Tooltip>
)}
{canReportSpam && (
<Tooltip content={spamLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
spamMutation({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
disabled={selectedThreadIds.size === 0}
icon={<Icon name={spamIconName} type={IconType.OUTLINED} />}
variant="tertiary"
size="nano"
aria-label={spamLabel}
/>
</Tooltip>
)}
{canTrash && (
<Tooltip content={trashLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
trashMutation({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
disabled={selectedThreadIds.size === 0}
icon={<Icon name={trashIconName} type={IconType.OUTLINED} />}
variant="tertiary"
size="nano"
aria-label={trashLabel}
/>
</Tooltip>
)}
{canDeleteDrafts && (
<Tooltip content={t('Delete drafts')} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
deleteDrafts({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
disabled={selectedThreadIds.size === 0}
icon={<Icon name="edit_off" type={IconType.OUTLINED} />}
variant="tertiary"
size="nano"
aria-label={t('Delete draft')}
/>
</Tooltip>
)}
{canAssignLabel && (
<LabelsWidget
threadIds={Array.from(selectedThreadIds)}
/>
)}
<VerticalSeparator withPadding={false} />
</>
)}
<DropdownMenu
isOpen={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
options={[
{
label: isSelectionMode ? t('Disable thread selection') : t('Select threads'),
icon: <Icon name="checklist" />,
callback: () => {
if (isSelectionMode) {
onDisableSelectionMode();
} else {
onEnableSelectionMode();
}
},
showSeparator: true,
},
...([SelectionReadStatus.MIXED, SelectionReadStatus.UNREAD].includes(selectionReadStatus) ? [{
label: markAllTooltip,
icon: <Icon name="drafts" />,
callback: () => {
markAsReadAt({
threadIds: threadIdsToMark,
readAt: new Date().toISOString(),
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
},
}] : []),
...([SelectionReadStatus.MIXED, SelectionReadStatus.READ, SelectionReadStatus.NONE].includes(selectionReadStatus) ? [{
label: markAllUnreadLabel,
icon: <Icon name="mark_email_unread" />,
callback: () => {
// Close the open thread before the mutation so the visibility
// observer cannot re-mark the newly-unread messages as read.
unselectThread();
onClearSelection();
markAsReadAt({
threadIds: threadIdsToMark,
readAt: null,
});
},
}] : []),
...(isSelectionMode && selectedThreadIds.size > 0 && ([SelectionStarredStatus.MIXED, SelectionStarredStatus.UNSTARRED, SelectionStarredStatus.NONE].includes(selectionStarredStatus)) ? [{
label: starLabel,
icon: <Icon name="star_border" type={IconType.OUTLINED} />,
callback: () => {
markAsStarred({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
},
}] : []),
...(isSelectionMode && selectedThreadIds.size > 0 && ([SelectionStarredStatus.MIXED, SelectionStarredStatus.STARRED].includes(selectionStarredStatus)) ? [{
label: unstarLabel,
icon: <Icon name="star" type={IconType.FILLED} />,
callback: () => {
markAsUnstarred({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
},
}] : []),
]}
>
<Tooltip content={t('More options')}>
// Surface the pull-to-refresh result in place of the count for a moment,
// then revert to the count once the feedback has been seen.
useEffect(() => {
if (!refreshFeedback) return;
const timeoutId = window.setTimeout(
() => onClearRefreshFeedback?.(),
REFRESH_FEEDBACK_DURATION_MS,
);
return () => window.clearTimeout(timeoutId);
}, [refreshFeedback, onClearRefreshFeedback]);
const detailsLabel = isRefreshing
? t('Fetching mail…')
: refreshFeedback ?? countLabel;
// With several accessible mailboxes, prefix the count with the active
// mailbox so the user always knows which box they are looking at. Only on
// viewports where the left panel (and its mailbox selector) is hidden —
// on desktop the selector already shows it.
const mailboxName = selectedMailbox?.name?.trim() || selectedMailbox?.email;
const showMailboxName = !isDesktop && (mailboxes?.length ?? 0) > 1 && !!mailboxName;
const detailsTitle = showMailboxName ? `${mailboxName} · ${detailsLabel}` : detailsLabel;
// Same control either side of the selection bar / count switch below.
const selectAllCheckbox = (isSelectionMode || isSomeSelected) && (
<Checkbox
checked={isAllSelected}
indeterminate={isSomeSelected && !isAllSelected}
onChange={handleSelectAllToggle}
aria-label={isAllSelected ? t('Deselect all threads') : t('Select all threads')}
className="thread-panel__header--checkbox"
/>
);
const actionsBar = (
<div className={clsx("thread-panel__bar", {
"thread-panel__bar--floating": hasFloatingActions,
})}>
<Tooltip content={mainReadTooltip}>
<Button
onClick={() => {
// Close the open thread before firing the mutation. Waiting for
// onSuccess would let the visibility observer re-observe the
// newly-unread messages and debounce a mark-as-read that silently
// reverts the action.
unselectThread();
onClearSelection();
markAsReadAt({
threadIds: threadIdsToMark,
readAt: selectionReadStatus === SelectionReadStatus.READ ? null : new Date().toISOString(),
});
}}
icon={<Icon name={selectionReadStatus === SelectionReadStatus.READ ? 'mail-unread' : 'mail-open'} type={IconType.OUTLINED} />}
variant="tertiary"
size={actionButtonSize}
aria-label={mainReadTooltip}
/>
</Tooltip>
{isSelectionMode && (
<>
{hasSelectionActions && <VerticalSeparator withPadding={false} />}
{canArchive && (
<Tooltip content={archiveLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => setIsDropdownOpen(true)}
icon={<Icon name="more_vert" />}
onClick={() => {
archiveMutation({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
disabled={selectedThreadIds.size === 0}
icon={<Icon {...archiveIconProps} />}
variant="tertiary"
aria-label={t('More options')}
size="nano"
size={actionButtonSize}
aria-label={archiveLabel}
/>
</Tooltip>
</DropdownMenu>
</div>
)}
{canReportSpam && (
<Tooltip content={spamLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
spamMutation({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
disabled={selectedThreadIds.size === 0}
icon={<Icon {...spamIconProps} />}
variant="tertiary"
size={actionButtonSize}
aria-label={spamLabel}
/>
</Tooltip>
)}
{canTrash && (
<Tooltip content={trashLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
trashMutation({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
disabled={selectedThreadIds.size === 0}
icon={<Icon {...trashIconProps} />}
variant="tertiary"
size={actionButtonSize}
aria-label={trashLabel}
/>
</Tooltip>
)}
{canDeleteDrafts && (
<Tooltip content={t('Delete drafts')} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
deleteDrafts({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
disabled={selectedThreadIds.size === 0}
icon={<Icon name="edit_off" type={IconType.OUTLINED} />}
variant="tertiary"
size={actionButtonSize}
aria-label={t('Delete draft')}
/>
</Tooltip>
)}
{canAssignLabel && (
<LabelsWidget
threadIds={Array.from(selectedThreadIds)}
/>
)}
<VerticalSeparator withPadding={false} />
</>
)}
<DropdownMenu
isOpen={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
options={[
{
label: isSelectionMode ? t('Disable thread selection') : t('Select threads'),
icon: <Icon icon={TodoList} />,
callback: () => {
if (isSelectionMode) {
onDisableSelectionMode();
} else {
onEnableSelectionMode();
}
},
showSeparator: true,
},
...([SelectionReadStatus.MIXED, SelectionReadStatus.UNREAD].includes(selectionReadStatus) ? [{
label: markAllTooltip,
icon: <Icon name="mail-open" />,
callback: () => {
markAsReadAt({
threadIds: threadIdsToMark,
readAt: new Date().toISOString(),
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
},
}] : []),
...([SelectionReadStatus.MIXED, SelectionReadStatus.READ, SelectionReadStatus.NONE].includes(selectionReadStatus) ? [{
label: markAllUnreadLabel,
icon: <Icon name="mail-unread" />,
callback: () => {
// Close the open thread before the mutation so the visibility
// observer cannot re-mark the newly-unread messages as read.
unselectThread();
onClearSelection();
markAsReadAt({
threadIds: threadIdsToMark,
readAt: null,
});
},
}] : []),
...(canStarSelection && isSelectionMode && selectedThreadIds.size > 0 && ([SelectionStarredStatus.MIXED, SelectionStarredStatus.UNSTARRED, SelectionStarredStatus.NONE].includes(selectionStarredStatus)) ? [{
label: starLabel,
icon: <Star />,
callback: () => {
markAsStarred({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
},
}] : []),
...(canStarSelection && isSelectionMode && selectedThreadIds.size > 0 && ([SelectionStarredStatus.MIXED, SelectionStarredStatus.STARRED].includes(selectionStarredStatus)) ? [{
label: unstarLabel,
icon: <StarFilled />,
callback: () => {
markAsUnstarred({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
},
}] : []),
]}
>
<Tooltip content={t('More options')}>
<Button
onClick={() => setIsDropdownOpen(true)}
icon={<Icon icon={MoreVertical} />}
variant="tertiary"
aria-label={t('More options')}
size={actionButtonSize}
/>
</Tooltip>
</DropdownMenu>
</div>
);
const headerText = (
<>
<div className="thread-panel__header--title-row">
{showSelectionBar ? (
<h2 className="thread-panel__header--title">
{t('{{count}} selected threads', { count: selectedThreadIds.size })}
</h2>
) : (
<>
<h2 className="thread-panel__header--title">{title}</h2>
{/* On the native app the filter lives in the bottom bar. */}
{!isNativePlatform() && <ThreadPanelFilter />}
</>
)}
</div>
<div className={clsx("thread-panel__header--details", {
"thread-panel__header--details--selection": showSelectionBar,
})}>
{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.
<div className="thread-panel__selection-controls">
{selectAllCheckbox}
<Button
onClick={onDisableSelectionMode}
icon={<Icon name="close" type={IconType.OUTLINED} />}
variant="tertiary"
color="neutral"
size={actionButtonSize}
aria-label={t('Disable thread selection')}
/>
</div>
) : (
<>
{selectAllCheckbox}
<p className="thread-panel__header--count" title={detailsTitle}>
{showMailboxName && (
<>
<span className="thread-panel__header--mailbox">{mailboxName}</span>
<span aria-hidden="true"> · </span>
</>
)}
{detailsLabel}
</p>
</>
)}
{/* Off touch the actions stay inline with the count. */}
{!hasFloatingActions && actionsBar}
</div>
</>
);
return (
<header className="thread-panel__header">
{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.
<div className={clsx("thread-panel__header--columns", {
"thread-panel__header--columns--selection": showSelectionBar,
})}>
<div className="thread-panel__header--text">{headerText}</div>
{actionsBar}
</div>
) : headerText}
</header>
)
}
@@ -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);
@@ -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<HTMLDivElement>) => {
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 ? (
<div className="thread-panel__empty">
@@ -118,27 +160,38 @@ export const ThreadPanel = () => {
</div>
</div>
) : (
<div
className="thread-panel__threads_list"
ref={scrollContainerRef}
onScroll={handleScroll}
role="listbox"
aria-multiselectable="true"
aria-label={t('Thread list')}
onKeyDown={handleListboxKeyDown}
onBlur={handleListboxBlur}
>
<>
{isNative && <PullToRefreshIndicator ref={indicatorRef} />}
<div
className="thread-panel__threads_list"
ref={setThreadsListNode}
onScroll={handleThreadsListScroll}
role="listbox"
aria-multiselectable="true"
aria-label={t('Thread list')}
onKeyDown={handleListboxKeyDown}
onBlur={handleListboxBlur}
>
{threads?.results.map((thread) => (
<ThreadItem
// Swiping and selecting compete for the same finger:
// while a selection is running, taps stay dedicated to
// picking rows.
<ThreadItemSwipe
key={thread.id}
thread={thread}
isSelected={selectedThreadIds.has(thread.id)}
onToggle={toggleThread}
onSelectRange={selectRange}
selectedThreadIds={selectedThreadIds}
isSelectionMode={isSelectionMode}
{...getItemProps(thread.id)}
/>
enabled={isNative && !isSelectionMode && selectedThreadIds.size === 0}
actions={rowActions}
>
<ThreadItem
thread={thread}
isSelected={selectedThreadIds.has(thread.id)}
onToggle={toggleThread}
onSelectRange={selectRange}
selectedThreadIds={selectedThreadIds}
isSelectionMode={isSelectionMode}
{...getItemProps(thread.id)}
/>
</ThreadItemSwipe>
))}
{threads!.next && (
<div className="thread-panel__page-loader" ref={loaderRef}>
@@ -150,7 +203,8 @@ export const ThreadPanel = () => {
)}
</div>
)}
</div>
</div>
</>
)}
</div>
);
@@ -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;
@@ -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 type={IconType.FILLED} name="link" />,
icon: <Icon icon={Link} />,
callback: () => copyDeepLink({ messageId: message.id }),
},
...(onDelete ? [{
label: t('Delete draft'),
icon: <Icon type={IconType.FILLED} name="delete" />,
icon: <Icon icon={Trash} />,
callback: onDelete,
showSeparator: true,
}] : []),
@@ -40,7 +42,7 @@ const DraftActionsMenu = ({ message, onDelete }: DraftActionsMenuProps) => {
<Tooltip content={t('More options')} placement="left">
<Button
onClick={() => setIsOpen(true)}
icon={<Icon type={IconType.FILLED} name="more_vert" />}
icon={<Icon icon={MoreVertical} />}
color="brand"
variant="tertiary"
size="small"
@@ -31,6 +31,12 @@ export type ThreadAccessesWidgetHandle = {
type ThreadAccessesWidgetProps = {
accesses: readonly ThreadAccessDetail[];
/**
* Render only the ShareModal, without the "members" trigger button.
* Used on mobile where the modal is opened through the `open()` handle
* (more-options drawer, assignees widget) instead of a dedicated button.
*/
hideTrigger?: boolean;
};
/**
@@ -55,7 +61,7 @@ type EnrichedAccess = ThreadAccessDetail & {
* returning a ReactNode for the per-mailbox user list).
*/
export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, ThreadAccessesWidgetProps>(
function ThreadAccessesWidget({ accesses }, ref) {
function ThreadAccessesWidget({ accesses, hideTrigger = false }, ref) {
const { t } = useTranslation();
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -318,18 +324,20 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
return (
<>
<Tooltip content={t('See members of this thread ({{count}} members)', { count: accesses.length })}>
<Button
variant="tertiary"
size="nano"
aria-label={t('See members of this thread ({{count}} members)', { count: accesses.length })}
className="thread-accesses-widget"
onClick={() => setIsShareModalOpen(true)}
icon={<Icon name="group" type={IconType.FILLED} />}
>
{accesses.length}
</Button>
</Tooltip>
{!hideTrigger && (
<Tooltip content={t('See members of this thread ({{count}} members)', { count: accesses.length })}>
<Button
variant="tertiary"
size="nano"
aria-label={t('See members of this thread ({{count}} members)', { count: accesses.length })}
className="thread-accesses-widget"
onClick={() => setIsShareModalOpen(true)}
icon={<Icon name="group" type={IconType.FILLED} />}
>
{accesses.length}
</Button>
</Tooltip>
)}
<ShareModal<MailboxLight, MailboxLight, EnrichedAccess>
modalTitle={isAssignmentContext ? t('Share and assign the thread') : t('Share the thread')}
isOpen={isShareModalOpen}
@@ -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);
}
@@ -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<ThreadAccessesWidgetHandle>(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(<ToasterItem><p>{t('You left the thread')}</p></ToasterItem>);
},
});
};
return (
<div className="thread-action-bar__container">
<div className="thread-action-bar">
<AssigneesWidget onClick={() => accessesWidgetRef.current?.open()} />
</div>
<div className="thread-action-bar">
<Tooltip content={t('Close this thread')} placement="bottom">
<Button
onClick={unselectThread}
variant="tertiary"
aria-label={t('Close this thread')}
size="nano"
icon={<Icon name="close" />}
/>
</Tooltip>
<VerticalSeparator />
{canEditThread && !isDraftsView && (
canUndelete ? (
<Tooltip content={t('Undelete')}>
<Button
variant="tertiary"
aria-label={t('Undelete')}
size="nano"
icon={<Icon name="restore_from_trash" type={IconType.OUTLINED} />}
onClick={() => markAsUntrashed({ threadIds: [selectedThread!.id], onSuccess: unselectThread })}
/>
</Tooltip>
) : (
<Tooltip content={t('Delete')}>
<Button
variant="tertiary"
aria-label={t('Delete')}
size="nano"
icon={<Icon icon={Trash} />}
onClick={() => markAsTrashed({ threadIds: [selectedThread!.id], onSuccess: unselectThread })}
/>
</Tooltip>
)
)}
{canEditThread && isDraftsView && (
<Tooltip content={t('Delete draft')}>
<Button
@@ -124,13 +115,13 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
variant="tertiary"
aria-label={t('Archive')}
size="nano"
icon={<Icon name="archive" type={IconType.OUTLINED} />}
icon={<Icon icon={Archive} />}
onClick={() => markAsArchived({ threadIds: [selectedThread!.id], onSuccess: unselectThread })}
/>
</Tooltip>
)
)}
{canEditThread && !isDraftsView && (
{canShowSpamCTA && !isDraftsView && (
!selectedThread?.is_spam ? (
<Tooltip content={t('Report as spam')}>
<Button
@@ -153,29 +144,6 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
</Tooltip>
)
)}
{canEditThread && !isDraftsView && (
canUndelete ? (
<Tooltip content={t('Undelete')}>
<Button
variant="tertiary"
aria-label={t('Undelete')}
size="nano"
icon={<Icon name="restore_from_trash" type={IconType.OUTLINED} />}
onClick={() => markAsUntrashed({ threadIds: [selectedThread!.id], onSuccess: unselectThread })}
/>
</Tooltip>
) : (
<Tooltip content={t('Delete')}>
<Button
variant="tertiary"
aria-label={t('Delete')}
size="nano"
icon={<Icon name="delete" type={IconType.OUTLINED} />}
onClick={() => markAsTrashed({ threadIds: [selectedThread!.id], onSuccess: unselectThread })}
/>
</Tooltip>
)
)}
{canEditThread && <VerticalSeparator />}
{hasUnread ? (
<Tooltip content={t('Mark as read')}>
@@ -183,7 +151,7 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
variant="tertiary"
aria-label={t('Mark as read')}
size="nano"
icon={<Icon name="drafts" type={IconType.OUTLINED} />}
icon={<Icon name="mail-open" />}
onClick={() => markAsReadAt({ threadIds: [selectedThread!.id], readAt: new Date().toISOString() })}
/>
</Tooltip>
@@ -193,7 +161,7 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
variant="tertiary"
aria-label={t('Mark as unread')}
size="nano"
icon={<Icon name="mark_email_unread" type={IconType.OUTLINED} />}
icon={<Icon name="mail-unread" />}
onClick={() => {
unselectThread();
markAsReadAt({ threadIds: [selectedThread!.id], readAt: null });
@@ -201,28 +169,7 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
/>
</Tooltip>
)}
{isStarred ? (
<Tooltip content={t('Unstar this thread')}>
<Button
variant="tertiary"
aria-label={t('Unstar this thread')}
size="nano"
icon={<Icon name="star" />}
onClick={() => markAsUnstarred({ threadIds: [selectedThread!.id] })}
/>
</Tooltip>
) : (
<Tooltip content={t('Star this thread')}>
<Button
variant="tertiary"
aria-label={t('Star this thread')}
size="nano"
icon={<Icon name="star_border" />}
onClick={() => markAsStarred({ threadIds: [selectedThread!.id] })}
/>
</Tooltip>
)}
<LabelsWidget threadIds={[selectedThread!.id]} initialLabels={selectedThread!.labels} />
{!isTrashContext && <LabelsWidget threadIds={[selectedThread!.id]} initialLabels={selectedThread!.labels} />}
<ThreadAccessesWidget ref={accessesWidgetRef} accesses={selectedThread!.accesses} />
<DropdownMenu
isOpen={isDropdownOpen}
@@ -231,7 +178,7 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
...(showDraftThreadManagement ? [
{
label: t('Archive'),
icon: <Icon name="archive" type={IconType.OUTLINED} />,
icon: <Icon icon={Archive} />,
callback: () => markAsArchived({ threadIds: [selectedThread!.id], onSuccess: unselectThread }),
},
{
@@ -241,27 +188,27 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
},
{
label: t('Move to trash'),
icon: <Icon name="delete" type={IconType.OUTLINED} />,
icon: <Icon icon={Trash} />,
callback: () => markAsTrashed({ threadIds: [selectedThread!.id], onSuccess: unselectThread }),
showSeparator: true,
},
] : []),
{
label: t('Copy link to thread'),
icon: <Icon name="link" type={IconType.OUTLINED} />,
icon: <Icon icon={Link} />,
callback: () => copyDeepLink(),
},
...(canLeaveThread ? [{
label: t('Leave this thread'),
icon: <Icon name="exit_to_app" type={IconType.OUTLINED} />,
callback: handleLeaveThread,
callback: leaveThread,
}] : []),
]}
>
<Tooltip content={t('More options')}>
<Button
onClick={() => setIsDropdownOpen(true)}
icon={<Icon name="more_vert" type={IconType.OUTLINED} />}
icon={<Icon icon={MoreVertical} />}
variant="tertiary"
aria-label={t('More options')}
size="nano"

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