(frontend) add label assignment with archive and bulk label widget

Dragging threads onto a label now assigns the label and archive by default.
Holding Shift while dropping only assign the label to the threads, mimicking
Gmail's "move" behavior. A custom drag preview follows the cursor
and updates in real-time to reflect the current action.

A new BulkLabelsWidget in the thread selection toolbar lets users
assign labels to multiple threads at once.
The existing LabelsPopup was refactored to support
multi-thread selection with indeterminate checkbox states.

Resolve #396
This commit is contained in:
jbpenrath
2026-04-14 19:41:03 +02:00
parent a7dc4b4ef9
commit 78c1842d99
27 changed files with 833 additions and 408 deletions

View File

@@ -291,22 +291,19 @@ class LabelViewSet(
{"detail": "No thread IDs provided"},
status=status.HTTP_400_BAD_REQUEST,
)
# Require full edit rights on each thread **scoped to the label's
# own mailbox**. The mailbox here is intrinsic to the label (a
# property of the resource), not ambient from the caller's view,
# so we always pass `label.mailbox_id`. This enforces two things
# at once:
# - User must have full edit rights (EDITOR ThreadAccess role +
# CAN_EDIT MailboxAccess role) on the thread — blocks viewer
# access to the thread.
# - The thread must actually exist under label.mailbox — blocks
# attaching a label to a thread that is only visible from a
# different mailbox, even when the user has edit rights there.
# Labels are a mailbox-local organizational tool: the caller's
# EDITOR/SENDER/ADMIN role on the label's mailbox (verified by
# `check_mailbox_permissions` above) is sufficient to attach the
# label. We don't require per-thread EDITOR rights, so a viewer
# of a shared thread can still organize it within their own
# mailbox. We still require the thread to belong to the label's
# mailbox — otherwise a caller could attach a label to any
# thread ID they happen to know.
accessible_threads = models.Thread.objects.filter(
Exists(
models.ThreadAccess.objects.editable_by(
request.user, mailbox_id=label.mailbox_id
).filter(thread=OuterRef("pk"))
models.ThreadAccess.objects.filter(
mailbox_id=label.mailbox_id, thread=OuterRef("pk")
)
),
id__in=thread_ids,
)
@@ -360,16 +357,14 @@ class LabelViewSet(
{"detail": "No thread IDs provided"},
status=status.HTTP_400_BAD_REQUEST,
)
# Removing a label from a thread is a mutation on shared state,
# so it requires full edit rights (EDITOR ThreadAccess role +
# CAN_EDIT MailboxAccess role) scoped to the label's own mailbox
# — same check as add_threads. See the comment there for the
# rationale on using `label.mailbox_id` (intrinsic to the label).
# Mirrors `add_threads`: mailbox-level permission is sufficient
# for mailbox-local label organization; we only require the
# thread to belong to the label's mailbox.
accessible_threads = models.Thread.objects.filter(
Exists(
models.ThreadAccess.objects.editable_by(
request.user, mailbox_id=label.mailbox_id
).filter(thread=OuterRef("pk"))
models.ThreadAccess.objects.filter(
mailbox_id=label.mailbox_id, thread=OuterRef("pk")
)
),
id__in=thread_ids,
)

View File

@@ -662,49 +662,44 @@ class TestLabelViewSet:
assert response.status_code == status.HTTP_200_OK
assert label.threads.count() == 1 # Thread not removed
def test_add_threads_cross_mailbox_viewer_thread_access_forbidden(
self, api_client, label, user
def test_add_threads_viewer_thread_access_allowed(
self, api_client, label, mailbox, user
):
"""User is EDITOR/ADMIN on label.mailbox (can manage labels) but
only has VIEWER ThreadAccess to the thread (via a different
mailbox). Labelling the thread is a shared-state mutation on the
thread, so it must be blocked.
"""A label is a mailbox-local organizational tool: mailbox-level
permission on label.mailbox is enough to attach it. The caller's
per-thread role is irrelevant — a VIEWER on a shared thread can
still label it from within their own mailbox.
"""
other_mailbox = MailboxFactory()
other_mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.VIEWER)
thread = ThreadFactory()
thread.accesses.create(
mailbox=other_mailbox,
role=enums.ThreadAccessRoleChoices.EDITOR, # Mailbox has edit access
mailbox=mailbox,
role=enums.ThreadAccessRoleChoices.VIEWER,
)
url = reverse("labels-add-threads", args=[label.pk])
response = api_client.post(url, {"thread_ids": [str(thread.id)]}, format="json")
# The endpoint returns 200 but silently skips the thread.
assert response.status_code == status.HTTP_200_OK
assert label.threads.count() == 0
assert label.threads.count() == 1
def test_remove_threads_cross_mailbox_viewer_thread_access_forbidden(
self, api_client, label, user
def test_remove_threads_viewer_thread_access_allowed(
self, api_client, label, mailbox, user
):
"""Mirror of add_threads above — removing a label from a thread
also requires full edit rights on the thread.
"""Mirror of add_threads above — removing a label only requires
mailbox-level permission on label.mailbox.
"""
other_mailbox = MailboxFactory()
other_mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.VIEWER)
thread = ThreadFactory()
thread.accesses.create(
mailbox=other_mailbox,
role=enums.ThreadAccessRoleChoices.EDITOR,
mailbox=mailbox,
role=enums.ThreadAccessRoleChoices.VIEWER,
)
label.threads.add(thread) # Pre-existing association
label.threads.add(thread)
url = reverse("labels-remove-threads", args=[label.pk])
response = api_client.post(url, {"thread_ids": [str(thread.id)]}, format="json")
assert response.status_code == status.HTTP_200_OK
assert label.threads.count() == 1 # Not removed
assert label.threads.count() == 0
def test_add_threads_thread_not_in_label_mailbox_forbidden(
self, api_client, label, user

View File

@@ -75,8 +75,6 @@
"{{count}} threads have been unarchived._other": "{{count}} threads have been unarchived.",
"{{count}} threads have been updated._one": "The thread has been updated.",
"{{count}} threads have been updated._other": "{{count}} threads have been updated.",
"{{count}} threads selected_one": "{{count}} thread selected",
"{{count}} threads selected_other": "{{count}} threads selected",
"{{count}} unread messages_one": "{{count}} unread message",
"{{count}} unread messages_other": "{{count}} unread messages",
"{{count}} unread messages mentioning you_one": "{{count}} unread message mentioning you",
@@ -148,6 +146,8 @@
"Are you sure you want to delete this signature? This action is irreversible!": "Are you sure you want to delete this signature? This action is irreversible!",
"Are you sure you want to delete this template? This action is irreversible!": "Are you sure you want to delete this template? This action is irreversible!",
"Are you sure you want to reset the password?": "Are you sure you want to reset the password?",
"Assign this label": "Assign this label",
"Assign this label and archive": "Assign this label and archive",
"At least one recipient is required.": "At least one recipient is required.",
"Attachment failed to be saved into your {{driveAppName}}'s workspace.": "Attachment failed to be saved into your {{driveAppName}}'s workspace.",
"Attachment saved into your {{driveAppName}}'s workspace.": "Attachment saved into your {{driveAppName}}'s workspace.",
@@ -366,8 +366,13 @@
"Integration updated!": "Integration updated!",
"Integrations": "Integrations",
"just now": "just now",
"Label \"{{label}}\" assigned and {{count}} threads archived._one": "Label \"{{label}}\" assigned and thread archived.",
"Label \"{{label}}\" assigned and {{count}} threads archived._other": "Label \"{{label}}\" assigned and {{count}} threads archived.",
"Label \"{{label}}\" assigned to {{count}} threads._one": "Label \"{{label}}\" assigned to this thread.",
"Label \"{{label}}\" assigned to {{count}} threads._other": "Label \"{{label}}\" assigned to {{count}} threads.",
"Label \"{{label}}\" assigned, but no threads could be archived.": "Label \"{{label}}\" assigned, but no threads could be archived.",
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._one": "Label \"{{label}}\" assigned. {{count}} of {{total}} thread archived.",
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._other": "Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived.",
"Label \"{{label}}\" removed from this conversation.": "Label \"{{label}}\" removed from this conversation.",
"Label name": "Label name",
"Labels": "Labels",
@@ -418,6 +423,8 @@
"Monthly": "Monthly",
"More": "More",
"More options": "More options",
"Move {{count}} threads_one": "Move {{count}} thread",
"Move {{count}} threads_other": "Move {{count}} threads",
"My auto-replies": "My auto-replies",
"My message templates": "My message templates",
"My signatures": "My signatures",

View File

@@ -113,9 +113,6 @@
"{{count}} threads have been updated._one": "La conversation a été mise à jour.",
"{{count}} threads have been updated._many": "{{count}} conversations ont été mises à jour.",
"{{count}} threads have been updated._other": "{{count}} conversations ont été mises à jour.",
"{{count}} threads selected_one": "{{count}} conversation sélectionnée",
"{{count}} threads selected_many": "{{count}} conversations sélectionnées",
"{{count}} threads selected_other": "{{count}} conversations sélectionnées",
"{{count}} unread messages_one": "{{count}} message non lu",
"{{count}} unread messages_many": "{{count}} messages non lus",
"{{count}} unread messages_other": "{{count}} messages non lus",
@@ -198,6 +195,8 @@
"Are you sure you want to delete this signature? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette signature ? Cette action est irréversible !",
"Are you sure you want to delete this template? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer ce modèle ? Cette action est irréversible !",
"Are you sure you want to reset the password?": "Êtes-vous sûr de vouloir réinitialiser le mot de passe ?",
"Assign this label": "Assigner ce libellé",
"Assign this label and archive": "Assigner ce libellé et archiver",
"At least one recipient is required.": "Il faut au moins un destinataire.",
"Attachment failed to be saved into your {{driveAppName}}'s workspace.": "Impossible de sauvegarder la pièce jointe dans votre espace de travail {{driveAppName}}.",
"Attachment saved into your {{driveAppName}}'s workspace.": "Pièce jointe sauvegardée dans votre espace de travail {{driveAppName}}.",
@@ -350,7 +349,7 @@
"Every {{count}} years_many": "Tous les {{count}} ans",
"Every {{count}} years_other": "Tous les {{count}} ans",
"Expand": "Développer",
"Expand {{name}}": "",
"Expand {{name}}": "Développer {{name}}",
"Expand all": "Tout développer",
"Failed to delete auto-reply.": "Erreur lors de la suppression de la réponse automatique.",
"Failed to delete integration.": "Erreur lors de la suppression de l'intégration.",
@@ -420,9 +419,16 @@
"Integration updated!": "Intégration mise à jour !",
"Integrations": "Intégrations",
"just now": "à l'instant",
"Label \"{{label}}\" assigned and {{count}} threads archived._one": "Libellé \"{{label}}\" assigné et conversation archivée.",
"Label \"{{label}}\" assigned and {{count}} threads archived._many": "Libellé \"{{label}}\" assigné et {{count}} conversations archivées.",
"Label \"{{label}}\" assigned and {{count}} threads archived._other": "Libellé \"{{label}}\" assigné et {{count}} conversations archivées.",
"Label \"{{label}}\" assigned to {{count}} threads._one": "Libellé \"{{label}}\" assigné à la conversation.",
"Label \"{{label}}\" assigned to {{count}} threads._many": "Libellé \"{{label}}\" assigné à {{count}} conversations.",
"Label \"{{label}}\" assigned to {{count}} threads._other": "Libellé \"{{label}}\" assigné à {{count}} conversations.",
"Label \"{{label}}\" assigned, but no threads could be archived.": "Libellé \"{{label}}\" assigné, mais aucune conversation n'a pu être archivée.",
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._one": "Libellé \"{{label}}\" assigné. {{count}} conversation sur {{total}} archivée.",
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._many": "Libellé \"{{label}}\" assigné. {{count}} conversations sur {{total}} archivées.",
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._other": "Libellé \"{{label}}\" assigné. {{count}} conversations sur {{total}} archivées.",
"Label \"{{label}}\" removed from this conversation.": "Libellé \"{{label}}\" retiré de cette conversation.",
"Label name": "Nom du libellé",
"Labels": "Libellés",
@@ -473,6 +479,9 @@
"Monthly": "Mensuel",
"More": "Plus",
"More options": "Plus d'options",
"Move {{count}} threads_one": "Déplacer {{count}} conversation",
"Move {{count}} threads_many": "Déplacer {{count}} conversations",
"Move {{count}} threads_other": "Déplacer {{count}} conversations",
"My auto-replies": "Mes réponses automatiques",
"My message templates": "Mes modèles de message",
"My signatures": "Mes signatures",

View File

@@ -4,7 +4,7 @@ import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useState, useRef } from "react";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { SearchFiltersForm } from "../search-filters-form";
import { useLayoutContext } from "@/features/layouts/components/main";
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";

View File

@@ -9,8 +9,7 @@ import ErrorPage from "next/error";
import { Toaster } from "@/features/ui/components/toaster";
import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit";
import { useTheme } from "@/features/providers/theme";
import { useState } from "react";
import { LayoutContext } from "../main";
import { LayoutProvider } from "@/features/layouts/components/layout-context";
type AdminLayoutProps = {
children: React.ReactNode;
@@ -121,18 +120,10 @@ function AdminLayoutContent({
}
export function AdminLayout(props: AdminLayoutProps) {
const [leftPanelOpen, setLeftPanelOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const { theme, variant } = useTheme();
return (
<LayoutContext.Provider value={{
toggleLeftPanel: () => setLeftPanelOpen(!leftPanelOpen),
closeLeftPanel: () => setLeftPanelOpen(false),
openLeftPanel: () => setLeftPanelOpen(true),
isDragging,
setIsDragging,
}}>
<LayoutProvider>
<AppLayout
isLeftPanelOpen={false}
setIsLeftPanelOpen={() => { }}
@@ -146,6 +137,6 @@ export function AdminLayout(props: AdminLayoutProps) {
<Toaster />
</AdminMailDomainProvider>
</AppLayout>
</LayoutContext.Provider>
</LayoutProvider>
);
}

View File

@@ -1,8 +1,4 @@
.thread-labels-widget {
position: relative;
}
.thread-labels-widget[aria-busy="true"] {
.labels-widget[aria-busy="true"] {
cursor: wait;
& > .c__button {
@@ -10,7 +6,7 @@
}
}
.thread-labels-widget__loading-labels-tooltip-content {
.labels-widget__loading-labels-tooltip-content {
display: inline-flex;
align-items: center;
gap: var(--c--globals--spacings--xs);
@@ -20,21 +16,18 @@
}
}
.thread-labels-widget__popup {
position: absolute;
right: -25%;
top: 100%;
.labels-widget__popup {
display: flex;
flex-direction: column;
background-color: var(--c--contextuals--background--surface--secondary);
border: 1px solid var(--c--contextuals--border--surface--primary);
border-radius: 4px;
box-shadow: 0 2px 5px 0 var(--c--contextuals--background--semantic--contextual--primary-hover);
z-index: 10000;
width: min(400px, 60vw);
max-height: max(600px, 80vh);
margin-top: var(--c--globals--spacings--2xs);
}
.thread-labels-widget__popup__header {
.labels-widget__popup__header {
display: flex;
flex-direction: column;
gap: var(--c--globals--spacings--xs);
@@ -54,18 +47,19 @@
}
}
.thread-labels-widget__popup__content {
.labels-widget__popup__content {
display: flex;
flex-direction: column;
gap: var(--c--globals--spacings--xs);
list-style: none;
padding: var(--c--globals--spacings--sm) var(--c--globals--spacings--base);
margin: 0;
max-height: 300px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.thread-labels-widget__popup__overlay {
.labels-widget__popup__overlay {
position: fixed;
top: 0;
left: 0;
@@ -74,14 +68,14 @@
z-index: 1000;
}
.thread-labels-widget__popup__content__empty .c__button {
.labels-widget__popup__content__empty .c__button {
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
& > .thread-labels-widget__popup__content__empty__button-label {
& > .labels-widget__popup__content__empty__button-label {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;

View File

@@ -0,0 +1,250 @@
import { ThreadLabel, TreeLabel, useLabelsAddThreadsCreate, useLabelsList, useLabelsRemoveThreadsCreate } from "@/features/api/gen";
import { Thread } from "@/features/api/gen/models";
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
import { Button, Checkbox, Input, Tooltip, useModal } from "@gouvfr-lasuite/cunningham-react";
import { RefObject, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useMailboxContext } from "@/features/providers/mailbox";
import StringHelper from "@/features/utils/string-helper";
import useAbility, { Abilities } from "@/hooks/use-ability";
import { usePopupPosition } from "@/hooks/use-popup-position";
import { LabelModal } from "@/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-form-modal";
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[];
}
export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) => {
const { t } = useTranslation();
const { selectedMailbox, threads } = useMailboxContext();
const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox);
const { data: labelsList, isLoading: isLoadingLabelsList } = useLabelsList(
{ mailbox_id: selectedMailbox!.id },
{ query: { enabled: canManageLabels } }
);
const [isPopupOpen, setIsPopupOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const labelCounts = useMemo(() => {
const counts = new Map<string, number>();
const fromThreads = threads?.results.filter((thread: Thread) => threadIds.includes(thread.id)) ?? [];
if (fromThreads.length > 0) {
for (const thread of fromThreads) {
for (const label of thread.labels) {
counts.set(label.id, (counts.get(label.id) ?? 0) + 1);
}
}
} else if (initialLabels && threadIds.length === 1) {
for (const label of initialLabels) {
counts.set(label.id, 1);
}
}
return counts;
}, [threads?.results, threadIds, initialLabels]);
if (!canManageLabels) return null;
if (isLoadingLabelsList) {
return (
<div className="labels-widget" aria-busy={true}>
<Tooltip
content={
<span className="labels-widget__loading-labels-tooltip-content">
<Spinner size="sm" />
{t('Loading labels...')}
</span>
}
>
<Button
size="nano"
variant="tertiary"
aria-label={t('Add label')}
icon={<Icon type={IconType.OUTLINED} name="new_label" />}
/>
</Tooltip>
</div>
);
}
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>
{isPopupOpen && (
<LabelsPopup
anchorRef={anchorRef}
onClose={() => setIsPopupOpen(false)}
labels={labelsList!.data || []}
threadIds={threadIds}
labelCounts={labelCounts}
/>
)}
</div>
);
};
export type LabelsPopupProps = {
labels: TreeLabel[];
threadIds: string[];
labelCounts: Map<string, number>;
anchorRef: RefObject<HTMLElement | null>;
onClose: () => void;
}
type LabelOption = {
label: string;
value: string;
checked: boolean;
indeterminate: boolean;
}
export const LabelsPopup = ({ labels = [], threadIds, labelCounts, anchorRef, onClose }: LabelsPopupProps) => {
const { t } = useTranslation();
const {open, close, isOpen} = useModal();
const [searchQuery, setSearchQuery] = useState('');
const { invalidateThreadMessages } = useMailboxContext();
const totalThreads = threadIds.length;
const position = usePopupPosition(anchorRef, true, (rect) => {
const top = rect.bottom + 4;
return {
top,
right: Math.max(8, window.innerWidth - rect.right - 100),
maxHeight: Math.min(300, Math.max(0, window.innerHeight - top - 8)),
};
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.stopImmediatePropagation();
onClose();
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [onClose]);
const getFlattenLabelOptions = (label: TreeLabel): LabelOption[] => {
const children: LabelOption[] = label.children.length > 0
? label.children.flatMap((child) => getFlattenLabelOptions(child))
: [];
const count = labelCounts.get(label.id) ?? 0;
const checked = totalThreads > 0 && count === totalThreads;
const indeterminate = count > 0 && count < totalThreads;
return [{
label: label.name,
value: label.id,
checked,
indeterminate,
}, ...children];
}
const labelsOptions = labels
.flatMap((label) => getFlattenLabelOptions(label))
.filter((option) => {
const normalizedLabel = StringHelper.normalizeForSearch(option.label);
const normalizedSearchQuery = StringHelper.normalizeForSearch(searchQuery);
return normalizedLabel.includes(normalizedSearchQuery);
})
.sort((a, b) => {
if (a.checked !== b.checked) return a.checked ? -1 : 1;
if (a.indeterminate !== b.indeterminate) return a.indeterminate ? -1 : 1;
return a.label.localeCompare(b.label);
});
const addLabelMutation = useLabelsAddThreadsCreate({
mutation: {
onSuccess: () => invalidateThreadMessages()
}
});
const deleteLabelMutation = useLabelsRemoveThreadsCreate({
mutation: {
onSuccess: () => invalidateThreadMessages()
}
});
const handleAddLabel = (labelId: string) => {
addLabelMutation.mutate({
id: labelId,
data: { thread_ids: threadIds },
});
}
const handleDeleteLabel = (labelId: string) => {
deleteLabelMutation.mutate({
id: labelId,
data: { thread_ids: threadIds },
});
}
const handleToggle = (option: LabelOption) => {
if (option.checked) {
handleDeleteLabel(option.value);
} else {
handleAddLabel(option.value);
}
}
if (!position) return null;
return createPortal(
<>
<div className="labels-widget__popup__overlay" onClick={onClose}></div>
<div
className="labels-widget__popup"
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={open} 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>
<LabelModal
isOpen={isOpen}
onClose={close}
label={{ display_name: searchQuery }}
onSuccess={(label) => { handleAddLabel(label.id)}}
/>
</li>
</ul>
</div>
</>,
document.body
);
};

View File

@@ -0,0 +1,89 @@
import { createContext, PropsWithChildren, useContext, useEffect, useMemo, useRef, useState } from "react";
type LayoutContextBase = {
isLeftPanelOpen: boolean;
setIsLeftPanelOpen: (open: boolean) => void;
toggleLeftPanel: () => void;
closeLeftPanel: () => void;
openLeftPanel: () => void;
};
type LayoutDragContext = {
isDragging: boolean;
setIsDragging: (prevState: boolean) => void;
dragAction: string | null;
setDragAction: (action: string | null) => void;
// Safari fallback: `e.shiftKey` on drag events is always `false` in
// WebKit, so callers OR it with this getter. The ref is kept fresh by
// a document-level keydown/keyup listener. Caveat: Safari also stops
// firing keyboard events entirely while a drag is in progress, so on
// Safari the returned value is effectively the Shift state at
// `dragstart` time — pressing/releasing Shift mid-drag has no effect.
// Chrome/Firefox populate `e.shiftKey` natively, so the getter is only
// the safety net. Exposed as a getter (not state) to avoid rerendering
// every consumer on each keystroke.
getIsShiftHeld: () => boolean;
};
export type LayoutContextType = LayoutContextBase & Partial<LayoutDragContext>;
const LayoutContext = createContext<LayoutContextType | undefined>(undefined);
type LayoutProviderProps = PropsWithChildren<{
draggable?: boolean;
}>;
export const LayoutProvider = ({ children, draggable = false }: LayoutProviderProps) => {
const [isLeftPanelOpen, setIsLeftPanelOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragAction, setDragAction] = useState<string | null>(null);
const isShiftHeldRef = useRef(false);
useEffect(() => {
if (!draggable) return;
const handler = (e: KeyboardEvent) => {
isShiftHeldRef.current = e.shiftKey;
};
window.addEventListener('keydown', handler);
window.addEventListener('keyup', handler);
return () => {
window.removeEventListener('keydown', handler);
window.removeEventListener('keyup', handler);
};
}, [draggable]);
const value = useMemo<LayoutContextType>(() => {
const base: LayoutContextBase = {
isLeftPanelOpen,
setIsLeftPanelOpen,
toggleLeftPanel: () => setIsLeftPanelOpen(!isLeftPanelOpen),
closeLeftPanel: () => setIsLeftPanelOpen(false),
openLeftPanel: () => setIsLeftPanelOpen(true),
};
if (!draggable) return base;
return {
...base,
isDragging,
setIsDragging,
dragAction,
setDragAction,
getIsShiftHeld: () => isShiftHeldRef.current,
};
}, [draggable, isLeftPanelOpen, isDragging, dragAction]);
return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>;
};
export const useLayoutContext = () => {
const context = useContext(LayoutContext);
if (!context) throw new Error("useLayoutContext must be used within a LayoutProvider");
return context;
};
export const useLayoutDragContext = (): LayoutContextBase & LayoutDragContext => {
const context = useLayoutContext();
if (context.setIsDragging === undefined) {
throw new Error("useLayoutDragContext requires a drag-enabled LayoutProvider");
}
return context as LayoutContextBase & LayoutDragContext;
};

View File

@@ -2,7 +2,7 @@ import { useRouter } from "next/router";
import { useTranslation } from "react-i18next";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { useMailboxContext } from "@/features/providers/mailbox";
import { useLayoutContext } from "../../../main";
import { useLayoutContext } from "@/features/layouts/components/layout-context";
import useAbility, { Abilities } from "@/hooks/use-ability";
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
import { useState } from "react";

View File

@@ -1,4 +1,5 @@
import { TreeLabel, ThreadsStatsRetrieveStatsFields, useLabelsDestroy, useLabelsList, useThreadsStatsRetrieve, ThreadsStatsRetrieve200, useLabelsAddThreadsCreate, useLabelsRemoveThreadsCreate, useLabelsPartialUpdate } from "@/features/api/gen";
import { TreeLabel, ThreadsStatsRetrieveStatsFields, useLabelsDestroy, useLabelsList, useThreadsStatsRetrieve, ThreadsStatsRetrieve200, useLabelsAddThreadsCreate, useLabelsRemoveThreadsCreate, useLabelsPartialUpdate, useFlagCreate } from "@/features/api/gen";
import { FlagEnum } from "@/features/api/gen/models";
import { getThreadsStatsQueryKey, useMailboxContext } from "@/features/providers/mailbox";
import { DropdownMenu, Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit";
import { Button, useModals } from "@gouvfr-lasuite/cunningham-react";
@@ -8,7 +9,7 @@ import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useLayoutContext } from "@/features/layouts/components/main";
import { useLayoutDragContext } from "@/features/layouts/components/layout-context";
import router from "next/router";
import { MAILBOX_FOLDERS } from "../../../mailbox-list";
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
@@ -16,6 +17,7 @@ import { toast } from "react-toastify";
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";
export type LabelTransferData = {
type: 'label';
@@ -48,7 +50,7 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
}
});
const unreadCount = (stats?.data as ThreadsStatsRetrieve200)?.all_unread ?? 0;
const { closeLeftPanel } = useLayoutContext();
const { closeLeftPanel, setDragAction, getIsShiftHeld } = useLayoutDragContext();
const pathname = usePathname();
const searchParams = useSearchParams();
const { t } = useTranslation();
@@ -58,6 +60,9 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
const foldKey = useMemo(() => `label-item-${label.display_name}${label.children.length > 0 ? `-with-children` : ''}`, [label.display_name, label.children.length]);
const { isFolded, toggle, setFoldState } = useFold(foldKey, isFoldedByDefault);
const foldTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const shouldAutoArchive = !ViewHelper.isArchivedView() && !ViewHelper.isSpamView() && !ViewHelper.isTrashedView() && !ViewHelper.isDraftsView();
const { mutate: flagMutate } = useFlagCreate();
const unfoldIfNeeded = useEffectEvent(() => {
if (isFolded) {
@@ -106,32 +111,9 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
const addThreadMutation = useLabelsAddThreadsCreate({
mutation: {
onSuccess: (_, variables) => {
// Invalidate relevant queries to refresh the UI
onSuccess: () => {
invalidateThreadMessages();
invalidateThreadsStats();
// Show success toast
const threadCount = variables.data.thread_ids!.length;
addToast(
<ToasterItem
type="info"
actions={[{
label: t('Undo'), onClick: () => deleteThreadMutation.mutate(variables)
}]}
>
<Icon name="label" type={IconType.OUTLINED} />
<span>{t('Label "{{label}}" assigned to {{count}} threads.', {
count: threadCount,
label: label.name,
defaultValue_one: "Label \"{{label}}\" assigned to this thread.",
defaultValue_other: "Label \"{{label}}\" assigned to {{count}} threads.",
})}</span>
</ToasterItem>, {
toastId: JSON.stringify(variables),
}
);
},
},
});
@@ -145,14 +127,29 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
name: label.name
}
} as LabelTransferData));
e.dataTransfer.effectAllowed = 'link'
}
const handleDragOver = (e: React.DragEvent<HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'link';
setIsDragOver(true);
if (e.dataTransfer.types.includes('text/thread-drag')) {
// Safari doesn't expose `e.shiftKey` on drag events, fall back to the
// globally-tracked ref.
const shiftHeld = e.shiftKey || getIsShiftHeld();
// Auto-archive also requires at least one editable thread in the
// dragged selection — archive is a shared-state mutation. The
// source advertises this via `text/thread-editable`. Labelling
// alone only needs mailbox-level `manage_labels` (enforced by
// `canManage` on the drop handlers below).
const hasEditable = e.dataTransfer.types.includes('text/thread-editable');
const action = !shiftHeld && shouldAutoArchive && hasEditable
? t('Assign this label and archive')
: t('Assign this label');
setDragAction(action);
}
if (!foldTimeoutRef.current) {
foldTimeoutRef.current = setTimeout(() => {
if (isFolded === true) toggle();
@@ -162,24 +159,125 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
const handleDragLeave = () => {
setIsDragOver(false);
setDragAction(null);
if (foldTimeoutRef.current) {
clearTimeout(foldTimeoutRef.current);
foldTimeoutRef.current = null;
}
};
const handleDropThread = (transferData: { threadIds?: string[], labels: string[] }) => {
const handleDropThread = (transferData: { threadIds?: string[], labels: string[], hasEditable?: boolean }, shiftKeyHeld: boolean = false) => {
const canBeAssigned = !transferData.labels.includes(label.id);
if (!canBeAssigned) return;
if (transferData.threadIds && transferData.threadIds.length > 0) {
addThreadMutation.mutate({
id: label.id,
data: {
thread_ids: transferData.threadIds,
},
});
}
if (!transferData.threadIds || transferData.threadIds.length === 0) return;
const threadIds = transferData.threadIds;
// Archive is gated on per-thread edit rights; without any editable
// thread we fall back to assign-only.
const doArchive = !shiftKeyHeld && shouldAutoArchive && transferData.hasEditable === true;
const toastId = `label-assign-${label.id}-${Date.now()}`;
addThreadMutation.mutate({
id: label.id,
data: { thread_ids: threadIds },
}, {
onSuccess: () => {
if (doArchive) {
flagMutate({
data: { flag: FlagEnum.archived, value: true, thread_ids: threadIds },
}, {
onSuccess: (response) => {
invalidateThreadMessages();
invalidateThreadsStats();
// Mirror the `useFlag` toast pattern: label assignment is
// fully successful under the current permission model (we
// relaxed the per-thread edit check; all dragged threads
// belong to the label's mailbox), so partial/none status
// is driven by the archive mutation alone.
const responseData = response.data as Record<string, unknown>;
const archivedCount = typeof responseData.updated_threads === 'number'
? responseData.updated_threads
: threadIds.length;
const submittedCount = threadIds.length;
const isNone = archivedCount === 0;
const isPartial = archivedCount > 0 && archivedCount < submittedCount;
const toastType = isNone ? 'error' : isPartial ? 'warning' : 'info';
const undo = () => {
deleteThreadMutation.mutate({
id: label.id,
data: { thread_ids: threadIds },
});
if (archivedCount > 0) {
flagMutate({
data: { flag: FlagEnum.archived, value: false, thread_ids: threadIds },
}, {
onSuccess: () => {
invalidateThreadMessages();
invalidateThreadsStats();
toast.dismiss(toastId);
},
});
} else {
toast.dismiss(toastId);
}
};
const mainMessage = isNone
? t('Label "{{label}}" assigned, but no threads could be archived.', { label: label.name })
: isPartial
? t('Label "{{label}}" assigned. {{count}} of {{total}} threads archived.', {
count: archivedCount,
total: submittedCount,
label: label.name,
})
: t('Label "{{label}}" assigned and {{count}} threads archived.', {
count: archivedCount,
label: label.name,
defaultValue_one: 'Label "{{label}}" assigned and thread archived.',
defaultValue_other: 'Label "{{label}}" assigned and {{count}} threads archived.',
});
addToast(
<ToasterItem type={toastType} actions={[{ label: t('Undo'), onClick: undo }]}>
<Icon name="label" type={IconType.OUTLINED} />
<div>
<p>{mainMessage}</p>
{(isPartial || isNone) && (
<p>{t('You may not have sufficient permissions for all selected threads.')}</p>
)}
</div>
</ToasterItem>,
{ toastId }
);
},
});
} else {
const undo = () => {
deleteThreadMutation.mutate({
id: label.id,
data: { thread_ids: threadIds },
});
toast.dismiss(toastId);
};
addToast(
<ToasterItem type="info" actions={[{ label: t('Undo'), onClick: undo }]}>
<Icon name="label" type={IconType.OUTLINED} />
<span>{t('Label "{{label}}" assigned to {{count}} threads.', {
count: threadIds.length,
label: label.name,
defaultValue_one: 'Label "{{label}}" assigned to this thread.',
defaultValue_other: 'Label "{{label}}" assigned to {{count}} threads.',
})}</span>
</ToasterItem>,
{ toastId }
);
}
},
});
}
const handleDropLabel = (transferData: LabelTransferData) => {
@@ -206,13 +304,14 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
setDragAction(null);
const rawData = e.dataTransfer.getData('application/json');
if (!rawData) return;
try {
const data = JSON.parse(rawData);
if (data.type === 'thread') handleDropThread(data);
if (data.type === 'thread') handleDropThread(data, e.shiftKey || getIsShiftHeld());
else if (data.type === 'label') handleDropLabel(data);
} catch (error) {
handle(new Error('Error parsing drag data.'), { extra: { error } });

View File

@@ -50,7 +50,6 @@ export const MailboxLabelsBase = ({ mailbox }: MailboxLabelsProps) => {
const handleDragOver = (e: React.DragEvent<HTMLElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
setIsDragOver(true);
};

View File

@@ -4,7 +4,7 @@ import clsx from "clsx"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { useMemo, useState } from "react"
import { useLayoutContext } from "../../../main"
import { useLayoutContext } from "@/features/layouts/components/layout-context"
import { useTranslation } from "react-i18next"
import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit"
import i18n from "@/features/i18n/initI18n";
@@ -340,9 +340,14 @@ const FolderItem = ({ folder, isChild, hasChildren, isExpanded, onToggleExpand,
const hasDeliveryFailed = (folderStats?.[ThreadsStatsRetrieveStatsFields.has_delivery_failed] ?? 0) > 0;
const handleDragOver = (e: React.DragEvent<HTMLAnchorElement>) => {
// All folder actions (archive/spam/trash and restore-to-inbox)
// mutate shared thread state, so the drop is refused when the
// dragged selection has no editable thread. The source advertises
// this via the `text/thread-editable` dataTransfer type (the JSON
// payload is not readable on dragover).
if (!e.dataTransfer.types.includes('text/thread-editable')) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'link';
setIsDragOver(true);
};
@@ -361,6 +366,10 @@ const FolderItem = ({ folder, isChild, hasChildren, isExpanded, onToggleExpand,
try {
const data = JSON.parse(rawData);
if (data.type !== 'thread' || !data.threadIds?.length) return;
// Defence in depth: dragover already filtered this out, but
// re-check on drop in case a source ever sets the JSON without
// the dataTransfer type (e.g. programmatic DnD in tests).
if (!data.hasEditable) return;
const threadIds = data.threadIds as string[];

View File

@@ -5,7 +5,7 @@ import { useMailboxContext } from "@/features/providers/mailbox";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { useRouter } from "next/router";
import { useSearchParams } from "next/navigation";
import { useLayoutContext } from "../main";
import { useLayoutContext } from "@/features/layouts/components/layout-context";
import { MailboxLabels } from "./components/mailbox-labels";
import { useState } from "react";

View File

@@ -211,7 +211,7 @@
border-top: 1px solid var(--c--contextuals--border--semantic--neutral--default);
}
// Tags Selector (reuses thread-labels-widget and cunningham styles)
// Tags Selector (reuses labels-widget and cunningham styles)
.tags-selector {
position: relative;
width: 100% !important;
@@ -257,7 +257,3 @@
min-height: 24px;
}
.tags-selector__popup {
left: 0;
right: auto;
}

View File

@@ -2,12 +2,14 @@ import { TreeLabel, ThreadLabel, useLabelsList } from "@/features/api/gen";
import { Icon, IconType, 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";
import { useTranslation } from "react-i18next";
import { useMailboxContext } from "@/features/providers/mailbox";
import StringHelper from "@/features/utils/string-helper";
import { LabelModal } from "@/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-form-modal";
import { Badge } from "@/features/ui/components/badge";
import { ColorHelper } from "@/features/utils/color-helper";
import { usePopupPosition } from "@/hooks/use-popup-position";
type TagsSelectorProps = {
selectedTags: string[];
@@ -43,6 +45,15 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
const [searchQuery, setSearchQuery] = useState('');
const [isPopupOpen, setIsPopupOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const position = usePopupPosition(containerRef, isPopupOpen, (rect) => {
const top = rect.bottom + 4;
const popupWidth = Math.min(400, window.innerWidth * 0.6);
return {
top,
left: Math.max(8, Math.min(rect.left, window.innerWidth - popupWidth - 8)),
maxHeight: Math.min(300, Math.max(0, window.innerHeight - top - 8)),
};
});
const { data: labelsList, isLoading } = useLabelsList(
{ mailbox_id: selectedMailbox?.id ?? '' },
@@ -162,13 +173,17 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
</LabelledBox>
</div>
{isPopupOpen && (
{isPopupOpen && position && createPortal(
<>
<div className="thread-labels-widget__popup tags-selector__popup">
<header className="thread-labels-widget__popup__header">
<div className="labels-widget__popup__overlay" onClick={() => setIsPopupOpen(false)}></div>
<div
className="labels-widget__popup"
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>
<Input
className="thread-labels-widget__popup__search"
className="labels-widget__popup__search"
type="search"
icon={<Icon type={IconType.OUTLINED} name="search" />}
label={t('Search a tag')}
@@ -177,7 +192,7 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
fullWidth
/>
</header>
<ul className="thread-labels-widget__popup__content">
<ul className="labels-widget__popup__content">
{labelsOptions.map((option) => (
<li key={option.id}>
<Checkbox
@@ -187,7 +202,7 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
/>
</li>
))}
<li className="thread-labels-widget__popup__content__empty">
<li className="labels-widget__popup__content__empty">
<Button
type="button"
color="brand"
@@ -196,7 +211,7 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
fullWidth
icon={<Icon type={IconType.OUTLINED} name="add" />}
>
<span className="thread-labels-widget__popup__content__empty__button-label">
<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')}
@@ -211,8 +226,8 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
</li>
</ul>
</div>
<div className="thread-labels-widget__popup__overlay" onClick={() => setIsPopupOpen(false)}></div>
</>
</>,
document.body
)}
</Field>
);

View File

@@ -16,7 +16,7 @@ import { MessageTemplateTypeChoices, StatusEnum, useMailboxesMessageTemplatesLis
import { CircularProgress } from "@/features/ui/components/circular-progress";
import { TaskImportCacheHelper } from "@/features/utils/task-import-cache";
import { useTheme } from "@/features/providers/theme";
import { useLayoutContext } from "..";
import { useLayoutContext } from "@/features/layouts/components/layout-context";
type AuthenticatedHeaderProps = HeaderProps & {

View File

@@ -1,5 +1,5 @@
import { AppLayout } from "./layout";
import { createContext, PropsWithChildren, useContext, useState } from "react";
import { PropsWithChildren } from "react";
import AuthenticatedView from "./authenticated-view";
import { MailboxProvider, useMailboxContext } from "@/features/providers/mailbox";
import { NoMailbox } from "./no-mailbox";
@@ -8,6 +8,7 @@ import { LeftPanel } from "./left-panel";
import { ModalStoreProvider } from "@/features/providers/modal-store";
import { ScrollRestoreProvider } from "@/features/providers/scroll-restore";
import { useTheme } from "@/features/providers/theme";
import { LayoutProvider, useLayoutDragContext } from "@/features/layouts/components/layout-context";
import Link from "next/link";
export const MainLayout = ({ children }: PropsWithChildren) => {
@@ -17,7 +18,9 @@ export const MainLayout = ({ children }: PropsWithChildren) => {
<MailboxProvider>
<SentBoxProvider>
<ModalStoreProvider>
<MainLayoutContent>{children}</MainLayoutContent>
<LayoutProvider draggable>
<MainLayoutContent>{children}</MainLayoutContent>
</LayoutProvider>
</ModalStoreProvider>
</SentBoxProvider>
</MailboxProvider>
@@ -26,52 +29,27 @@ export const MainLayout = ({ children }: PropsWithChildren) => {
)
}
type LayoutContextType = {
toggleLeftPanel: () => void;
closeLeftPanel: () => void;
openLeftPanel: () => void;
isDragging: boolean;
setIsDragging: (prevState: boolean) => void;
}
export const LayoutContext = createContext<LayoutContextType | undefined>(undefined);
const MainLayoutContent = ({ children }: PropsWithChildren<{ simple?: boolean }>) => {
const { mailboxes, queryStates } = useMailboxContext();
const hasNoMailbox = queryStates.mailboxes.status === 'success' && mailboxes!.length === 0;
const [leftPanelOpen, setLeftPanelOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const { theme, variant } = useTheme();
const { isLeftPanelOpen, setIsLeftPanelOpen, isDragging } = useLayoutDragContext();
return (
<LayoutContext.Provider value={{
toggleLeftPanel: () => setLeftPanelOpen(!leftPanelOpen),
closeLeftPanel: () => setLeftPanelOpen(false),
openLeftPanel: () => setLeftPanelOpen(true),
isDragging,
setIsDragging,
}}>
<AppLayout
enableResize
isLeftPanelOpen={leftPanelOpen}
setIsLeftPanelOpen={setLeftPanelOpen}
leftPanelContent={<LeftPanel hasNoMailbox={hasNoMailbox} />}
icon={<Link href="/"><img src={`/images/${theme}/app-logo-${variant}.svg`} alt="logo" height={40} /></Link>}
hideLeftPanelOnDesktop={hasNoMailbox}
isDragging={isDragging}
>
{hasNoMailbox ? (
<NoMailbox />
) : (
children
)}
</AppLayout>
</LayoutContext.Provider>
<AppLayout
enableResize
isLeftPanelOpen={isLeftPanelOpen}
setIsLeftPanelOpen={setIsLeftPanelOpen}
leftPanelContent={<LeftPanel hasNoMailbox={hasNoMailbox} />}
icon={<Link href="/"><img src={`/images/${theme}/app-logo-${variant}.svg`} alt="logo" height={40} /></Link>}
hideLeftPanelOnDesktop={hasNoMailbox}
isDragging={isDragging}
>
{hasNoMailbox ? (
<NoMailbox />
) : (
children
)}
</AppLayout>
)
}
export const useLayoutContext = () => {
const context = useContext(LayoutContext);
if (!context) throw new Error("useLayoutContext must be used within a LayoutContext.Provider");
return context;
}

View File

@@ -202,14 +202,53 @@
// Drag preview styles
.thread-drag-preview {
position: fixed;
top: 0;
left: -100px;
z-index: 100000;
background: var(--c--contextuals--background--semantic--brand--primary);
background: color-mix(in srgb, var(--c--contextuals--background--semantic--brand--primary) 90%, transparent);
backdrop-filter: blur(6px);
color: var(--c--contextuals--content--semantic--brand--on-brand);
border-radius: 50vw;
border: 1px solid var(--c--contextuals--border--semantic--brand--primary);
font-size: var(--c--globals--font--sizes--xs);
line-height: 1;
box-shadow: none;
font-weight: 700;
padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm);
display: inline-block;
white-space: nowrap;
pointer-events: none;
cursor: grabbing;
animation: popIn 0.15s var(--c--globals--transitions--ease-in-out);
transform-origin: left center;
}
.thread-drag-preview--exiting {
animation: popOut 0.15s var(--c--globals--transitions--ease-in-out) forwards;
}
@keyframes popIn {
0% {
scale: 0.7;
opacity: 0;
}
20% {
opacity: 0;
}
100% {
scale: 1;
opacity: 1;
}
}
@keyframes popOut {
0% {
scale: 1;
opacity: 1;
}
100% {
scale: 0.7;
opacity: 0;
}
}

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"
import Link from "next/link"
import { useParams, useSearchParams } from "next/navigation"
import { useMemo, useRef, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import clsx from "clsx"
import { DateHelper } from "@/features/utils/date-helper"
@@ -13,8 +13,9 @@ import { PORTALS } from "@/features/config/constants"
import { Checkbox } from "@gouvfr-lasuite/cunningham-react"
import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit"
import { LabelBadge } from "@/features/ui/components/label-badge"
import { useLayoutContext } from "../../../main"
import { useLayoutDragContext } from "@/features/layouts/components/layout-context"
import ViewHelper from "@/features/utils/view-helper"
import useCanEditThreads from "@/features/message/use-can-edit-threads"
type ThreadItemProps = {
thread: Thread
@@ -29,7 +30,8 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
const params = useParams<{ mailboxId: string, threadId: string }>()
const searchParams = useSearchParams()
const [isDragging, setIsDragging] = useState(false)
const { setIsDragging: setGlobalDragging } = useLayoutContext();
const [isExiting, setIsExiting] = useState(false)
const { setIsDragging: setGlobalDragging, setDragAction } = useLayoutDragContext();
const dragPreviewContainer = useRef(document.getElementById(PORTALS.DRAG_PREVIEW));
const threadDate = useMemo(() => {
if (ViewHelper.isInboxView() && thread.active_messaged_at) {
@@ -63,6 +65,16 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
const hasSelection = isSelectionMode || selectedThreadIds.size > 0;
const showCheckbox = hasSelection;
// Used by drop zones (folders, label auto-archive) to decide whether
// the dragged threads can be mutated. Expressed as a dataTransfer
// type so drop zones can read it on dragover — JSON payloads are only
// readable on drop per the browser security model.
const dragThreadIds = useMemo(
() => isSelectionMode ? Array.from(selectedThreadIds) : [thread.id],
[isSelectionMode, selectedThreadIds, thread.id]
);
const hasEditableInDrag = useCanEditThreads(dragThreadIds);
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggleSelection(thread.id, e.shiftKey, e.ctrlKey || e.metaKey);
@@ -91,27 +103,49 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
setIsDragging(true)
setGlobalDragging(true)
// If this thread is selected, drag all selected threads
const threadsToDrag = isSelectionMode ? Array.from(selectedThreadIds) : [thread.id];
e.dataTransfer.setData('application/json', JSON.stringify({
type: 'thread',
threadIds: threadsToDrag,
threadIds: dragThreadIds,
labels: isSelectionMode ? [] : thread.labels.map((label) => label.id),
hasEditable: hasEditableInDrag,
}));
e.dataTransfer.effectAllowed = 'link'
// Set the drag image
if (dragPreviewContainer.current) {
e.dataTransfer.setDragImage(dragPreviewContainer.current, 40, 40)
e.dataTransfer.setData('text/thread-drag', '');
// Advertised on dragover so folder drop zones can refuse drops
// when the dragged selection has no editable thread (archive,
// spam, trash and restore-to-inbox all require edit rights).
if (hasEditableInDrag) {
e.dataTransfer.setData('text/thread-editable', '');
}
// Hide native drag image by using an offscreen empty element as drag image
// (Safari-compatible — `new Image()` with an inline data URL is unreliable
// because Safari requires the image to be loaded/in the DOM).
// The ThreadDragPreview portal follows the cursor instead.
const ghost = document.createElement('div');
ghost.style.cssText = 'position:absolute;top:-1000px;left:-1000px;width:1px;height:1px;';
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
}
const handleDragEnd = () => {
setIsDragging(false);
setGlobalDragging(false);
setIsExiting(true);
};
const handleExitEnd = () => {
setIsExiting(false);
setDragAction(null);
};
const dragCount = selectedThreadIds.size > 0 ? selectedThreadIds.size : 1;
// Clear any pending drag action if the item unmounts before the
// exit animation completes (e.g. archived after label assignment),
// otherwise the stale action would leak into the next drag preview.
useEffect(() => () => setDragAction(null), [setDragAction]);
return (
<>
<Link
@@ -260,8 +294,12 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
</div>
</div>
</Link>
{isDragging && dragPreviewContainer.current && createPortal(
<ThreadDragPreview count={dragCount} />,
{(isDragging || isExiting) && dragPreviewContainer.current && createPortal(
<ThreadDragPreview
count={dragCount}
exiting={isExiting}
onExitEnd={handleExitEnd}
/>,
dragPreviewContainer.current
)}
</>

View File

@@ -1,18 +1,51 @@
import clsx from "clsx";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLayoutDragContext } from "@/features/layouts/components/layout-context";
type ThreadDragPreviewProps = {
count: number;
exiting?: boolean;
onExitEnd?: () => void;
};
/**
* This component is used to display a preview of a thread when it is being dragged.
* It aims to be rendered within the portal dedicated to drag preview '#drag-preview-container''
* Take a look at `_document.tsx`
* Custom drag preview that follows the cursor.
* The native drag image is hidden (transparent 1x1 pixel) so this component
* acts as the visible drag feedback. It reads `dragAction` from layout context
* to display the current drop action (e.g. "Assign + Archive").
*/
export const ThreadDragPreview = ({ count }: { count: number }) => {
export const ThreadDragPreview = ({ count, exiting, onExitEnd }: ThreadDragPreviewProps) => {
const { t } = useTranslation();
const { dragAction } = useLayoutDragContext();
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
// Follow the cursor and mark the whole document as a valid drop target
// (preventDefault on dragover) to suppress Chrome's native "snap-back"
// animation on drops outside real drop zones, which would otherwise
// delay `dragend` by ~500-800ms and defer our exit animation.
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`;
}
};
document.addEventListener('dragover', handler, true);
return () => document.removeEventListener('dragover', handler, true);
}, []);
return (
<span className="thread-drag-preview">
{t('{{count}} threads selected', {
<span
ref={ref}
className={clsx("thread-drag-preview", { "thread-drag-preview--exiting": exiting })}
onAnimationEnd={exiting ? onExitEnd : undefined}
>
{dragAction ?? t('Move {{count}} threads', {
count: count,
defaultValue_one: "{{count}} thread selected",
defaultValue_other: "{{count}} threads selected"
defaultValue_one: "Move {{count}} thread",
defaultValue_other: "Move {{count}} threads"
})}
</span>
)

View File

@@ -12,9 +12,11 @@ import useArchive from "@/features/message/use-archive";
import useSpam from "@/features/message/use-spam";
import useTrash from "@/features/message/use-trash";
import useStarred from "@/features/message/use-starred";
import useCanEditThreads from "@/features/message/use-can-edit-threads";
import { ThreadPanelFilter } from "./thread-panel-filter";
import { THREAD_PANEL_FILTER_PARAMS, useThreadPanelFilters } from "../hooks/use-thread-panel-filters";
import { SelectionReadStatus, SelectionStarredStatus } from "@/features/providers/thread-selection";
import { LabelsWidget } from "@/features/layouts/components/labels-widget";
type ThreadPanelTitleProps = {
selectedThreadIds: Set<string>;
@@ -50,17 +52,10 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
const { activeFilters } = useThreadPanelFilters();
// Whether at least one selected thread has full edit rights — gates
// shared-state mutations (archive, spam, trash). The backend enforces
// per-thread permissions; the toast reports the actual updated count.
// Star and read/unread are personal state on the user's ThreadAccess
// and remain available regardless.
const canEditSelection = useMemo(() => {
if (selectedThreadIds.size === 0) return false;
return Array.from(selectedThreadIds).some((id) => {
const thread = threads?.results.find((t) => t.id === id);
return thread?.abilities?.edit === true;
});
}, [selectedThreadIds, threads?.results]);
// shared-state mutations (archive, spam, trash). Star and read/unread
// are personal state on the user's ThreadAccess and remain available
// regardless.
const canEditSelection = useCanEditThreads(selectedThreadIds);
const title = useMemo(() => {
if (searchParams.has('search')) return t('folder.search', { defaultValue: 'Search' });
@@ -195,10 +190,10 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
aria-label={mainReadTooltip}
/>
</Tooltip>
{isSelectionMode && canEditSelection && (
{isSelectionMode && (
<>
<VerticalSeparator withPadding={false} />
{!isSpamView && !isTrashedView && !isDraftsView && (
{canEditSelection && !isSpamView && !isTrashedView && !isDraftsView && (
<Tooltip content={archiveLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
@@ -218,7 +213,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
/>
</Tooltip>
)}
{!isTrashedView && !isSentView && !isDraftsView && (
{canEditSelection && !isTrashedView && !isSentView && !isDraftsView && (
<Tooltip content={spamLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
@@ -238,7 +233,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
/>
</Tooltip>
)}
{!isDraftsView && (
{canEditSelection && !isDraftsView && (
<Tooltip content={trashLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
@@ -258,6 +253,11 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
/>
</Tooltip>
)}
{!isSpamView && !isTrashedView && !isDraftsView && (
<LabelsWidget
threadIds={Array.from(selectedThreadIds)}
/>
)}
<VerticalSeparator withPadding={false} />
</>
)}

View File

@@ -7,7 +7,7 @@ import { Button, Tooltip, useModals } from "@gouvfr-lasuite/cunningham-react"
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ThreadAccessesWidget } from "../thread-accesses-widget";
import { ThreadLabelsWidget } from "../thread-labels-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";
@@ -32,6 +32,8 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
// Full edit rights on the thread — gates archive, spam, delete.
// Star and "mark as unread" remain visible because they are personal
// state on the user's ThreadAccess (read_at / starred_at).
// 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
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
@@ -173,7 +175,7 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
/>
</Tooltip>
)}
<ThreadLabelsWidget threadId={selectedThread!.id} selectedLabels={selectedThread!.labels} />
<LabelsWidget threadIds={[selectedThread!.id]} initialLabels={selectedThread!.labels} />
<ThreadAccessesWidget accesses={selectedThread!.accesses} />
<DropdownMenu
isOpen={isDropdownOpen}

View File

@@ -1,185 +0,0 @@
import { ThreadLabel, TreeLabel, useLabelsAddThreadsCreate, useLabelsList, useLabelsRemoveThreadsCreate } from "@/features/api/gen";
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
import { Button, Checkbox, Input, Tooltip, useModal } from "@gouvfr-lasuite/cunningham-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMailboxContext } from "@/features/providers/mailbox";
import StringHelper from "@/features/utils/string-helper";
import useAbility, { Abilities } from "@/hooks/use-ability";
import { LabelModal } from "@/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-form-modal";
type ThreadLabelsWidgetProps = {
selectedLabels: readonly ThreadLabel[];
threadId: string;
}
export const ThreadLabelsWidget = ({ threadId, selectedLabels = [] }: ThreadLabelsWidgetProps) => {
const { t } = useTranslation();
const { selectedMailbox, selectedThread } = useMailboxContext();
const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox);
// Labelling a thread is a shared-state mutation: it also requires
// full edit rights on the thread itself (the label endpoint silently
// drops unauthorized threads otherwise).
const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread ?? null);
const canAddLabel = canManageLabels && canEditThread;
const {data: labelsList, isLoading: isLoadingLabelsList } = useLabelsList(
{ mailbox_id: selectedMailbox!.id },
{ query: { enabled: canAddLabel } }
);
const [isPopupOpen, setIsPopupOpen] = useState(false);
if (!canAddLabel) return null;
if (isLoadingLabelsList) {
return (
<div className="thread-labels-widget" aria-busy={true}>
<Tooltip
content={
<span className="thread-labels-widget__loading-labels-tooltip-content">
<Spinner size="sm" />
{t('Loading labels...')}
</span>
}
>
<Button
size="nano"
variant="tertiary"
aria-label={t('Add label')}
icon={<Icon type={IconType.OUTLINED} name="new_label" />}
/>
</Tooltip>
</div>
)
}
return (
<div className="thread-labels-widget">
<Tooltip content={t('Add label')}>
<Button
onClick={() => setIsPopupOpen(true)}
size="nano"
variant="tertiary"
aria-label={t('Add label')}
icon={<Icon type={IconType.OUTLINED} name="new_label" />}
/>
</Tooltip>
{isPopupOpen &&
<>
<LabelsPopup
labels={labelsList!.data || []}
selectedLabels={selectedLabels}
threadId={threadId}
/>
<div className="thread-labels-widget__popup__overlay" onClick={() => setIsPopupOpen(false)}></div>
</>
}
</div>
);
};
type LabelsPopupProps = {
labels: TreeLabel[];
threadId: string;
selectedLabels: readonly ThreadLabel[];
}
const LabelsPopup = ({ labels = [], selectedLabels, threadId }: LabelsPopupProps) => {
const { t } = useTranslation();
const {open, close, isOpen} = useModal();
const [searchQuery, setSearchQuery] = useState('');
const { invalidateThreadMessages } = useMailboxContext();
const getFlattenLabelOptions = (label: TreeLabel, level: number = 0): Array<{label: string, value: string, checked: boolean}> => {
let children: Array<{label: string, value: string, checked: boolean}> = [];
if (label.children.length > 0) {
children = label.children.map((child) => getFlattenLabelOptions(child, level + 1)).flat();
}
return [{
label: label.name,
value: label.id,
checked: selectedLabels.some((selectedLabel) => selectedLabel.id === label.id),
}, ...children];
}
const labelsOptions = labels
.map((label) => getFlattenLabelOptions(label))
.flat()
.filter((option) => {
const normalizedLabel = StringHelper.normalizeForSearch(option.label);
const normalizedSearchQuery = StringHelper.normalizeForSearch(searchQuery);
return normalizedLabel.includes(normalizedSearchQuery);
})
.sort((a, b) => {
if (a.checked !== b.checked) return a.checked ? -1 : 1;
return a.label.localeCompare(b.label);
});
const addLabelMutation = useLabelsAddThreadsCreate({
mutation: {
onSuccess: () => invalidateThreadMessages()
}
});
const deleteLabelMutation = useLabelsRemoveThreadsCreate({
mutation: {
onSuccess: () => invalidateThreadMessages()
}
});
const handleAddLabel = (labelId: string) => {
addLabelMutation.mutate({
id: labelId,
data: {
thread_ids: [threadId],
},
});
}
const handleDeleteLabel = (labelId: string) => {
deleteLabelMutation.mutate({
id: labelId,
data: {
thread_ids: [threadId],
},
});
}
return (
<div className="thread-labels-widget__popup">
<header className="thread-labels-widget__popup__header">
<h3><Icon type={IconType.OUTLINED} name="new_label" /> {t('Add labels')}</h3>
<Input
className="thread-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="thread-labels-widget__popup__content">
{labelsOptions.map((option) => (
<li key={option.value}>
<Checkbox
checked={option.checked}
onChange={() => option.checked ? handleDeleteLabel(option.value) : handleAddLabel(option.value)}
label={option.label}
/>
</li>
))}
<li className="thread-labels-widget__popup__content__empty">
<Button color="brand" variant="primary" onClick={open} fullWidth icon={<Icon type={IconType.OUTLINED} name="add" />}>
<span className="thread-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>
<LabelModal
isOpen={isOpen}
onClose={close}
label={{ display_name: searchQuery }}
onSuccess={(label) => { handleAddLabel(label.id)}}
/>
</li>
</ul>
</div>
);
};
LabelsPopup.displayName = 'LabelsPopup';

View File

@@ -0,0 +1,24 @@
import { useMemo } from "react";
import { useMailboxContext } from "@/features/providers/mailbox";
/**
* Returns whether at least one of the given threads grants full edit
* rights to the current user (`thread.abilities.edit === true`).
*
* Gates shared-state mutations (archive, spam, trash, auto-archive on
* label drop). The backend enforces per-thread permissions and reports
* the actual updated count in the toast — this hook only drives UI
* affordances (disabled buttons, blocked drop zones).
*/
const useCanEditThreads = (threadIds: Set<string> | string[]): boolean => {
const { threads } = useMailboxContext();
return useMemo(() => {
const ids = threadIds instanceof Set ? threadIds : new Set(threadIds);
if (ids.size === 0) return false;
return (threads?.results ?? []).some(
(t) => ids.has(t.id) && t.abilities?.edit === true
);
}, [threadIds, threads?.results]);
};
export default useCanEditThreads;

View File

@@ -0,0 +1,48 @@
import { RefObject, useLayoutEffect, useRef, useState } from "react";
/**
* Computes and keeps in sync the fixed-position coordinates of a portaled popup
* anchored to a trigger element. Recomputes on open, window resize, scroll (capture
* phase, so scrollable ancestors are covered), and when the anchor itself resizes
* — the latter via ResizeObserver, which prevents the popup from drifting when the
* anchor's content changes (e.g. badges added/removed while the popup stays open).
*
* The `compute` callback receives the anchor's DOMRect and returns whatever shape
* the caller needs (top/left, top/right, maxHeight, etc.), so positioning strategy
* stays at the call site.
*
* @returns the computed position, or null before the first measurement.
*/
export const usePopupPosition = <P,>(
anchorRef: RefObject<HTMLElement | null>,
isOpen: boolean,
compute: (rect: DOMRect) => P,
): P | null => {
const [position, setPosition] = useState<P | null>(null);
const computeRef = useRef(compute);
computeRef.current = compute;
useLayoutEffect(() => {
if (!isOpen) return;
const el = anchorRef.current;
if (!el) return;
const updatePosition = () => {
setPosition(computeRef.current(el.getBoundingClientRect()));
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
const observer = new ResizeObserver(updatePosition);
observer.observe(el);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
observer.disconnect();
};
}, [anchorRef, isOpen]);
return position;
};

View File

@@ -35,6 +35,7 @@
@use "./../features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item";
@use "./../features/layouts/components/thread-panel";
@use "./../features/layouts/components/thread-panel/components/thread-item";
@use "./../features/layouts/components/labels-widget";
@use "./../features/layouts/components/thread-view";
@use "./../features/layouts/components/thread-view/components/thread-action-bar";
@use "./../features/layouts/components/thread-view/components/thread-message";
@@ -43,7 +44,6 @@
@use "./../features/layouts/components/thread-view/components/thread-attachment-list";
@use "./../features/layouts/components/thread-view/components/calendar-invite";
@use "./../features/layouts/components/thread-view/components/thread-accesses-widget";
@use "./../features/layouts/components/thread-view/components/thread-labels-widget";
@use "./../features/forms/components/message-form";
@use "./../features/forms/components/search-input";
@use "./../features/forms/components/search-filters-form";