♻️(frontend) refactor thread query cache management (#642)

The thread query is an infinite one and the frontend logic is
based on the structuralSharing concept of react-query to
optimiscally update the react query cache on thread mutation in
order to improve ux. This part is a tricky one and it's easy to
introduce regression, that's why refactor it by moving the corresponding
logic into a mailbox-cache module, use a better naming (pin instead of optimistic)
and battle test it.
This commit is contained in:
Jean-Baptiste PENRATH
2026-05-06 19:39:07 +02:00
committed by GitHub
parent ad3307e448
commit 6be3b9e129
33 changed files with 1533 additions and 521 deletions
+184
View File
@@ -0,0 +1,184 @@
# Threads list cache — design & mutation contract
This document explains how the **frontend threads list cache** is managed,
why it pins certain threads, and what every mutation that touches threads
must do to keep the UI consistent.
It is required reading before adding or refactoring any hook that mutates
threads (read/unread, starred, archived, trashed, spam, draft delete/send,
labels, etc.).
The relevant code lives in:
- `src/frontend/src/features/providers/mailbox.tsx` — `MailboxProvider`
- `src/frontend/src/features/providers/mailbox-cache.ts` — pure cache helpers
- `src/frontend/src/features/message/use-*.tsx` — mutation hooks consuming the API
## 1. Server state vs. client state
There is **no global client state** for threads. The list comes from a
React Query infinite query (`useThreadsListInfinite`) keyed per mailbox and
per filter variant. The cache **is** the source of truth on the client; we
do not duplicate it into a Zustand/Redux store.
Two implications:
1. Every mutation reconciles by either **patching** the cache directly or
**invalidating** it (which triggers a refetch).
2. The cache is keyed by URL search params (filter, search, label, etc.).
The same thread can live in multiple cache variants simultaneously.
The query key is built by `getMailboxThreadsListQueryKey(mailboxId, searchParams)`:
```ts
['threads', <mailboxId>, 'list' | 'search', <normalized-other-params>]
```
`'list'` vs `'search'` lets us target the whole search subtree by prefix
without enumerating filter combinations.
## 2. Why pinning exists
Mutations like **mark-as-read** or **toggle-starred** flip a property that
the server uses to filter the list:
- Mark a thread as read while viewing the **"unread"** filter — the server
will drop it on the next refetch.
- Unstar a thread while viewing the **"starred"** filter — same problem.
Without protection, the thread would disappear from under the user's cursor
the moment a refetch lands (polling, `invalidateQueries`, window focus).
That is jarring: the user wants to *see* the action they just performed.
**Pinning** is the protection mechanism:
1. The mutation calls `pinThreads(ids, patcher)`. This:
- patches the thread(s) in every cached list variant via
`patchThreadsInCache` (so the UI reflects the new state immediately);
- records the thread ids in `pinnedThreadIdsRef` (a `useRef<Set<string>>`).
2. The infinite query's `structuralSharing` callback runs `mergePinnedThreads`
on every refetch. Threads that are pinned but missing from the new server
data are **re-injected** at the index they previously occupied within
their original page.
3. When the server returns a pinned thread on its own, the pin is **inert**:
`mergePinnedThreads` only re-injects threads missing from the response,
so the server's data wins by default. The pin entry stays in the set but
has no effect until the thread disappears again.
4. Pinned ids are cleared in bulk when the user changes mailbox or filter
(a `useEffect` watches `selectedMailbox.id` and `searchParams`).
### Pinning rules
- Pin **only** when the mutation flips a property the server filters on AND
the user should still see the thread under the current view.
- Always pair the pin with a cache patch — pinning a stale thread is worse
than dropping it, because it shows wrong data.
- Re-insertion preserves **per-page semantics**. Pinned threads never move
to page 0 if they originally lived on page 1; otherwise flattening the
pages would yield duplicates.
## 3. Why **unpinning** matters
A pin survives until `unpinThreads` is called or the user changes filter /
mailbox. When a mutation **moves the thread out of the active view**
(archive, spam, trash, draft delete, draft send), the server will *never*
return it under that filter again — so the pin would persist and
`mergePinnedThreads` would re-inject the thread on every subsequent
refetch. The user sees a "ghost" thread that ignores their action.
Every mutation that intentionally removes a thread from the current view
**must call** `unpinThreads(ids)` before invalidating the list.
```ts
const { unpinThreads, invalidateMailbox } = useMailboxContext();
onSuccess: (data) => {
unpinThreads(data.thread_ids ?? []);
invalidateMailbox();
}
```
Order matters less than presence: as long as the pin is gone before the
refetch settles, `structuralSharing` will let the thread disappear.
## 4. Mutation playbook
Every thread mutation falls into one of three categories. Pick the right
playbook and stick to it.
### A. "Stay visible" mutations (read/unread, starred/unstarred)
These flip a server-filterable property but the thread should remain
visible in the current view until the user navigates away.
```ts
onSuccess: (data) => {
pinThreads(data.thread_ids ?? [], (thread) => ({
...thread,
// recompute server-derived flags so the cached row matches reality
has_unread: deriveThreadHasUnread(thread.messaged_at, data.read_at ?? null),
accesses: thread.accesses.map((access) => ...),
}));
invalidateThreadsStats();
}
```
The patcher encodes the **domain semantics** (recomputing `has_unread`,
flipping `has_starred`, mutating `accesses`, etc.) — `mailbox-cache.ts`
does not know about them.
### B. "Leave the view" mutations (archive, spam, trash, draft delete/send)
These remove the thread from the active filter. We do not patch — we
unpin and let the next refetch reconcile.
```ts
onSuccess: (data) => {
unpinThreads(data.thread_ids ?? []);
invalidateMailbox();
invalidateThreadsStats();
}
```
### C. Per-thread message mutations (draft body, message read, etc.)
Patch the **per-thread messages cache** (`['messages', threadId]`) via
`patchMessagesInCache` / `removeMessagesFromCache`. These do not touch the
threads list. Invalidate `invalidateThreadMessages()` if a refetch is
warranted.
## 5. Invalidation helpers exposed by `MailboxProvider`
| Helper | When to call it |
|------------------------------|----------------------------------------------------------------------|
| `pinThreads(ids, patcher)` | "Stay visible" mutations (category A) |
| `unpinThreads(ids)` | "Leave the view" mutations (category B), before invalidating |
| `patchMessages(threadId, p)` | Per-thread message mutations (category C) |
| `removeMessages(...)` | Drop messages from a thread's cache (e.g. draft deletion) |
| `invalidateThreadList()` | Force refetch of every list variant of the current mailbox |
| `invalidateThreadMessages()` | Force refetch of the selected thread's messages |
| `invalidateMailbox()` | Shorthand for both above |
| `invalidateThreadEvents()` | Refetch events of the selected thread |
| `invalidateThreadsStats()` | Refetch sidebar counters (excludes per-label stats) |
| `invalidateLabels()` | Refetch the labels list |
## 6. Decision flow when adding a new mutation
1. **Does the mutation flip a property the server filters on?**
- No → category C (messages-only) or just invalidate.
- Yes → step 2.
2. **Should the thread remain in the current view after the mutation?**
- Yes → category A: `pinThreads(ids, patcher)` + invalidate stats only.
- No → category B: `unpinThreads(ids)` + `invalidateMailbox()`.
3. **Are there per-thread side effects (messages, events)?**
- Yes → also patch / invalidate the relevant per-thread caches.
When in doubt, write a unit test against `mergePinnedThreads` reproducing
the user-visible scenario before changing behaviour.
+7 -5
View File
@@ -1860,14 +1860,11 @@
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TreeLabel"
}
"$ref": "#/components/schemas/Label"
}
}
},
"description": "Created labels in hierarchical structure"
"description": "Created label"
},
"400": {
"content": {
@@ -6933,6 +6930,10 @@
"description": "Color of the label in hex format (e.g. #FF0000)",
"maxLength": 7
},
"display_name": {
"type": "string",
"readOnly": true
},
"mailbox": {
"type": "string",
"format": "uuid",
@@ -6958,6 +6959,7 @@
}
},
"required": [
"display_name",
"id",
"mailbox",
"name",
+6 -7
View File
@@ -539,7 +539,7 @@ class AttachmentSerializer(serializers.ModelSerializer):
class ThreadLabelSerializer(serializers.ModelSerializer):
"""Serializer to get labels details for a thread."""
display_name = serializers.SerializerMethodField(read_only=True)
display_name = serializers.CharField(source="get_display_name", read_only=True)
class Meta:
model = models.Label
@@ -554,10 +554,6 @@ class ThreadLabelSerializer(serializers.ModelSerializer):
]
read_only_fields = ["id", "slug", "display_name"]
def get_display_name(self, instance):
"""Return the display name of the label."""
return instance.name.split("/")[-1]
class TreeLabelSerializer(serializers.ModelSerializer):
"""Serializer for tree label response structure (OpenAPI purpose only...)."""
@@ -566,7 +562,7 @@ class TreeLabelSerializer(serializers.ModelSerializer):
name = serializers.CharField(read_only=True)
slug = serializers.CharField(read_only=True)
color = serializers.CharField(read_only=True)
display_name = serializers.CharField(read_only=True)
display_name = serializers.CharField(source="get_display_name", read_only=True)
children = serializers.SerializerMethodField(read_only=True)
description = serializers.CharField(read_only=True)
is_auto = serializers.BooleanField(read_only=True)
@@ -598,6 +594,8 @@ class TreeLabelSerializer(serializers.ModelSerializer):
class LabelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
"""Serializer for Label model."""
display_name = serializers.CharField(source="get_display_name", read_only=True)
class Meta:
model = models.Label
fields = [
@@ -605,12 +603,13 @@ class LabelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
"name",
"slug",
"color",
"display_name",
"mailbox",
"threads",
"description",
"is_auto",
]
read_only_fields = ["id", "slug"]
read_only_fields = ["id", "slug", "display_name"]
create_only_fields = ["mailbox"]
def validate_mailbox(self, value):
+2 -2
View File
@@ -198,8 +198,8 @@ class LabelViewSet(
request=serializers.LabelSerializer,
responses={
201: OpenApiResponse(
response=serializers.TreeLabelSerializer(many=True),
description="Created labels in hierarchical structure",
response=serializers.LabelSerializer,
description="Created label",
),
400: OpenApiResponse(
response={"detail": "Validation error"},
@@ -213,7 +213,7 @@ export function useLabelsList<
* View and manage labels
*/
export type labelsCreateResponse201 = {
data: TreeLabel[];
data: Label;
status: 201;
};
@@ -27,6 +27,7 @@ export interface Label {
* @maxLength 7
*/
color?: string;
readonly display_name: string;
/** Mailbox that owns this label */
mailbox: string;
/** Threads that have this label */
@@ -23,7 +23,7 @@ export type IMPORT_STEP = 'idle' | 'uploading' | 'importing' | 'completed';
* - completed : Importing completed once the task is SUCCESS
*/
export const ModalMessageImporter = () => {
const { invalidateThreadMessages, invalidateThreadsStats, invalidateLabels,refetchMailboxes, selectedMailbox } = useMailboxContext();
const { invalidateMailbox, invalidateThreadsStats, invalidateLabels, refetchMailboxes, selectedMailbox } = useMailboxContext();
const { t } = useTranslation();
const modals = useModals();
const taskImportCacheHelper = new TaskImportCacheHelper(selectedMailbox?.id);
@@ -66,7 +66,7 @@ export const ModalMessageImporter = () => {
await Promise.all([
refetchMailboxes(),
invalidateThreadsStats(),
invalidateThreadMessages(),
invalidateMailbox(),
invalidateLabels(),
]);
}
@@ -6,7 +6,7 @@ import { FormProvider, useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Attachment, DraftMessageRequestRequest, Message, sendCreateResponse200, useDraftCreate, useDraftUpdate2, useMessagesDestroy, useSendCreate } from "@/features/api/gen";
import { Attachment, DraftMessageRequestRequest, draftCreateResponse200, Message, sendCreateResponse200, useDraftCreate, useDraftUpdate2, useMessagesDestroy, useSendCreate } from "@/features/api/gen";
import { MessageComposer, MessageComposerHandle, QuoteType } from "@/features/forms/components/message-composer";
import { useMailboxContext } from "@/features/providers/mailbox";
import MailHelper from "@/features/utils/mail-helper";
@@ -97,7 +97,7 @@ export const MessageForm = ({
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const saveDraftRef = useRef<() => void>(() => {});
const quoteType: QuoteType | undefined = mode !== "new" ? (mode === "forward" ? "forward" : "reply") : undefined;
const { selectedMailbox, selectedThread, mailboxes, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext();
const { selectedMailbox, selectedThread, mailboxes, removeMessages, invalidateMailbox, invalidateThreadsStats, unselectThread, unpinThreads, pinThreads } = useMailboxContext();
const hideSubjectField = Boolean(draftMessage?.parent_id ?? parentMessage);
const defaultSenderId = mailboxes?.find((mailbox) => {
if (draft?.sender) return draft.sender.email === mailbox.email;
@@ -286,7 +286,21 @@ export const MessageForm = ({
const draftCreateMutation = useDraftCreate({
mutation: {
onSuccess: () => {
onSuccess: (response) => {
const message = (response as draftCreateResponse200).data;
// Patch + pin so a thread that just acquired a draft stays
// accurate even when filtered out of the next refetch (e.g.
// marked-as-read while viewing "unread"): mergePinnedThreads
// would otherwise re-insert the cached version with
// `has_draft: false` and the drafts filter would miss it.
if (message.thread_id) {
pinThreads([message.thread_id], (thread) => ({
...thread,
has_draft: true,
draft_messaged_at: message.created_at,
}));
}
invalidateMailbox();
invalidateThreadsStats();
handleDraftMutationSuccess();
}
@@ -318,7 +332,14 @@ export const MessageForm = ({
onSuccess: () => {
onClose?.();
setDraft(undefined);
invalidateThreadMessages({ type: 'delete', metadata: { ids: [messageId] } });
if (selectedThread) {
removeMessages(selectedThread.id, [messageId]);
// The thread may exit the active filter (e.g. drafts) once
// its only draft is gone. Drop any pin so the next refetch
// is authoritative.
unpinThreads([selectedThread.id]);
}
invalidateMailbox();
invalidateThreadsStats();
// Unselect the thread if we are in the draft view
if (searchParams.get('has_draft') === '1') {
@@ -511,6 +532,11 @@ export const MessageForm = ({
const { htmlBody, textBody } = await composerRef.current.exportContent();
stopAutoSave();
// Send (and "send and archive") moves the thread out of the drafts
// filter — and possibly out of the inbox when archived. Drop the
// pin upfront so the eventual refetch is authoritative.
const draftThreadId = draft?.thread_id ?? selectedThread?.id;
if (draftThreadId) unpinThreads([draftThreadId]);
messageMutation.mutate({
data: {
messageId,
@@ -1,4 +1,4 @@
import { ThreadLabel, TreeLabel, useLabelsAddThreadsCreate, useLabelsList, useLabelsRemoveThreadsCreate } from "@/features/api/gen";
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 { Button, Checkbox, Input, Tooltip } from "@gouvfr-lasuite/cunningham-react";
@@ -10,6 +10,8 @@ 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";
import useDeleteLabel from "@/features/message/use-delete-label";
import useAddLabel from "@/features/message/use-add-label";
type LabelsWidgetProps = {
threadIds: string[];
@@ -23,9 +25,30 @@ type CreateModalState = {
initialName: string;
}
// Project either a TreeLabel (from the labels list) or a Label (from the
// create endpoint) onto the ThreadLabel shape stored on `Thread.labels`.
const toThreadLabel = (label: TreeLabel | Label): ThreadLabel => ({
id: label.id,
name: label.name,
slug: label.slug,
color: label.color,
display_name: label.display_name,
description: label.description,
is_auto: label.is_auto,
});
const findTreeLabelById = (labels: readonly TreeLabel[], id: string): TreeLabel | undefined => {
for (const label of labels) {
if (label.id === id) return label;
const found = findTreeLabelById(label.children, id);
if (found) return found;
}
return undefined;
};
export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) => {
const { t } = useTranslation();
const { selectedMailbox, threads, invalidateThreadMessages } = useMailboxContext();
const { selectedMailbox, threads } = useMailboxContext();
const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox);
const { data: labelsList, isLoading: isLoadingLabelsList } = useLabelsList(
{ mailbox_id: selectedMailbox!.id },
@@ -35,24 +58,16 @@ export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) =>
const [createModal, setCreateModal] = useState<CreateModalState>({ isOpen: false, initialName: '' });
const anchorRef = useRef<HTMLDivElement>(null);
const addLabelMutation = useLabelsAddThreadsCreate({
mutation: { onSuccess: () => invalidateThreadMessages() }
});
const deleteLabelMutation = useLabelsRemoveThreadsCreate({
mutation: { onSuccess: () => invalidateThreadMessages() }
});
const { addLabel } = useAddLabel();
const { deleteLabel } = useDeleteLabel();
const handleAddLabel = (labelId: string) => {
addLabelMutation.mutate({
id: labelId,
data: { thread_ids: threadIds },
});
const treeLabel = findTreeLabelById(labelsList?.data ?? [], labelId);
if (!treeLabel) return;
addLabel({ label: toThreadLabel(treeLabel), threadIds });
}
const handleDeleteLabel = (labelId: string) => {
deleteLabelMutation.mutate({
id: labelId,
data: { thread_ids: threadIds },
});
const handleDeleteLabel = (labelId: string, labelSlug: string) => {
deleteLabel({ labelId, labelSlug, threadIds });
}
const labelCounts = useMemo(() => {
@@ -125,7 +140,7 @@ export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) =>
isOpen={createModal.isOpen}
onClose={() => setCreateModal((s) => ({ ...s, isOpen: false }))}
label={{ display_name: createModal.initialName }}
onSuccess={(label) => handleAddLabel(label.id)}
onSuccess={(label) => addLabel({ label: toThreadLabel(label), threadIds })}
/>
</div>
);
@@ -138,7 +153,7 @@ export type LabelsPopupProps = {
anchorRef: RefObject<HTMLElement | null>;
onClose: () => void;
onAddLabel: (labelId: string) => void;
onDeleteLabel: (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.
@@ -148,6 +163,7 @@ export type LabelsPopupProps = {
type LabelOption = {
label: string;
value: string;
slug: string;
checked: boolean;
indeterminate: boolean;
}
@@ -198,6 +214,7 @@ export const LabelsPopup = ({
return [{
label: label.name,
value: label.id,
slug: label.slug,
checked,
indeterminate,
}, ...children];
@@ -218,7 +235,7 @@ export const LabelsPopup = ({
const handleToggle = (option: LabelOption) => {
if (option.checked) {
onDeleteLabel(option.value);
onDeleteLabel(option.value, option.slug);
} else {
onAddLabel(option.value);
}
@@ -19,7 +19,7 @@ export type SubLabelCreation = Partial<Pick<TreeLabel, 'name' | 'color' | 'displ
type LabelModalProps = {
isOpen: boolean;
onClose: () => void;
onSuccess?: (label: TreeLabel) => void;
onSuccess?: (label: Label) => void;
label?: TreeLabel | SubLabelCreation
}
@@ -111,7 +111,7 @@ export const LabelModal = ({ isOpen, onClose, label, onSuccess }: LabelModalProp
newSearchParams.set('label_slug', (data.data as Label).slug);
router.push(`${pathname}?${newSearchParams.toString()}`);
}
onSuccess?.(data.data as TreeLabel);
onSuccess?.(data.data as Label);
handleClose();
}
});
@@ -1,6 +1,8 @@
import { TreeLabel, ThreadsStatsRetrieveStatsFields, useLabelsDestroy, useLabelsList, useThreadsStatsRetrieve, ThreadsStatsRetrieve200, useLabelsAddThreadsCreate, useLabelsRemoveThreadsCreate, useLabelsPartialUpdate, useFlagCreate } from "@/features/api/gen";
import { FlagEnum } from "@/features/api/gen/models";
import { TreeLabel, ThreadsStatsRetrieveStatsFields, useLabelsDestroy, useLabelsList, useThreadsStatsRetrieve, ThreadsStatsRetrieve200, useLabelsPartialUpdate } from "@/features/api/gen";
import { getThreadsStatsQueryKey, useMailboxContext } from "@/features/providers/mailbox";
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 { Button, useModals } from "@gouvfr-lasuite/cunningham-react";
import clsx from "clsx";
@@ -32,7 +34,7 @@ type LabelItemProps = TreeLabel & {
}
export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...label }: LabelItemProps) => {
const { selectedMailbox, invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { selectedMailbox, invalidateThreadsStats } = useMailboxContext();
const modals = useModals();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
@@ -62,7 +64,9 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
const foldTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const shouldAutoArchive = !ViewHelper.isArchivedView() && !ViewHelper.isSpamView() && !ViewHelper.isTrashedView() && !ViewHelper.isDraftsView();
const { mutate: flagMutate } = useFlagCreate();
// Suppress the default archive toast: we render our own combined
// "Label assigned + N archived" toast below.
const { markAsArchived, markAsUnarchived } = useArchive({ showToast: false });
const unfoldIfNeeded = useEffectEvent(() => {
if (isFolded) {
@@ -100,23 +104,18 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
toggle();
}
const deleteThreadMutation = useLabelsRemoveThreadsCreate({
mutation: {
onSuccess: (_, variables) => {
invalidateThreadMessages();
toast.dismiss(JSON.stringify(variables));
},
},
});
const addThreadMutation = useLabelsAddThreadsCreate({
mutation: {
onSuccess: () => {
invalidateThreadMessages();
invalidateThreadsStats();
},
},
});
const { deleteLabel } = useDeleteLabel();
const { addLabel } = useAddLabel();
// ThreadLabel shape (no `children`) for the local cache patcher in `addLabel`.
const threadLabel = useMemo(() => ({
id: label.id,
name: label.name,
slug: label.slug,
color: label.color,
display_name: label.display_name,
description: label.description,
is_auto: label.is_auto,
}), [label]);
const handleDragStart = (e: React.DragEvent<HTMLAnchorElement>) => {
e.dataTransfer.setData('application/json', JSON.stringify({
@@ -178,47 +177,35 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
const doArchive = !shiftKeyHeld && shouldAutoArchive && transferData.hasEditable === true;
const toastId = `label-assign-${label.id}-${Date.now()}`;
addThreadMutation.mutate({
id: label.id,
data: { thread_ids: threadIds },
}, {
addLabel({
label: threadLabel,
threadIds,
onSuccess: () => {
invalidateThreadsStats();
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;
markAsArchived({
threadIds,
onSuccess: (_, updatedCount) => {
// Label assignment is fully successful under the current
// permission model (all dragged threads belong to the
// label's mailbox), so partial/none status is driven by
// the archive mutation alone.
const archivedCount = updatedCount ?? 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 },
deleteLabel({
labelId: label.id,
labelSlug: label.slug,
threadIds,
});
if (archivedCount > 0) {
flagMutate({
data: { flag: FlagEnum.archived, value: false, thread_ids: threadIds },
}, {
onSuccess: () => {
invalidateThreadMessages();
invalidateThreadsStats();
toast.dismiss(toastId);
},
markAsUnarchived({
threadIds,
onSuccess: () => toast.dismiss(toastId),
});
} else {
toast.dismiss(toastId);
@@ -256,9 +243,10 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
});
} else {
const undo = () => {
deleteThreadMutation.mutate({
id: label.id,
data: { thread_ids: threadIds },
deleteLabel({
labelId: label.id,
labelSlug: label.slug,
threadIds,
});
toast.dismiss(toastId);
};
@@ -194,13 +194,15 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
<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(),
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
icon={<Icon name={selectionReadStatus === SelectionReadStatus.READ ? 'mark_email_unread' : 'mark_email_read'} type={IconType.OUTLINED} />}
@@ -314,13 +316,13 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
label: markAllUnreadLabel,
icon: <span className="material-icons">mark_email_unread</span>,
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,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
},
}] : []),
@@ -4,9 +4,10 @@ import { useState } from "react";
import { ThreadAccessRoleChoices, ThreadAccessDetail, MailboxLight } from "@/features/api/gen/models";
import { useMailboxContext } from "@/features/providers/mailbox";
import { useTranslation } from "react-i18next";
import { useMailboxesSearchList, useThreadsAccessesCreate, useThreadsAccessesDestroy, useThreadsAccessesUpdate } from "@/features/api/gen";
import { useMailboxesSearchList, useThreadsAccessesCreate, useThreadsAccessesUpdate } from "@/features/api/gen";
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
import useAbility, { Abilities } from "@/hooks/use-ability";
import useDeleteThreadAccess from "@/features/message/use-delete-thread-access";
@@ -27,11 +28,11 @@ export const ThreadAccessesWidget = ({ accesses }: ThreadAccessesWidgetProps) =>
const { t } = useTranslation();
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { selectedMailbox, selectedThread, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext();
const { selectedMailbox, selectedThread, invalidateMailbox } = useMailboxContext();
const modals = useModals();
const { mutate: removeThreadAccess } = useThreadsAccessesDestroy({ mutation: { onSuccess: () => invalidateThreadMessages() } });
const { mutate: createThreadAccess } = useThreadsAccessesCreate({ mutation: { onSuccess: () => invalidateThreadMessages() } });
const { mutate: updateThreadAccess } = useThreadsAccessesUpdate({ mutation: { onSuccess: () => invalidateThreadMessages() } });
const { deleteThreadAccess } = useDeleteThreadAccess();
const { mutate: createThreadAccess } = useThreadsAccessesCreate({ mutation: { onSuccess: () => invalidateMailbox() } });
const { mutate: updateThreadAccess } = useThreadsAccessesUpdate({ mutation: { onSuccess: () => invalidateMailbox() } });
const searchMailboxesQuery = useMailboxesSearchList(selectedMailbox?.id ?? "", {
q: searchQuery,
}, {
@@ -105,24 +106,18 @@ export const ThreadAccessesWidget = ({ accesses }: ThreadAccessesWidgetProps) =>
),
});
if (decision !== 'delete') return;
removeThreadAccess({
id: access.id,
threadId: selectedThread!.id
}, {
deleteThreadAccess({
accessId: access.id,
accessMailboxId: access.mailbox.id,
threadId: selectedThread!.id,
onSuccess: () => {
addToast(<ToasterItem>
<p>{t('Thread access removed')}</p>
</ToasterItem>);
if (isSelfRemoval) {
setIsShareModalOpen(false);
invalidateThreadMessages({
type: 'delete',
metadata: { threadIds: [selectedThread!.id] },
});
invalidateThreadsStats();
unselectThread();
}
}
},
});
}
@@ -11,7 +11,8 @@ 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 { MailboxRoleChoices, ThreadAccessRoleChoices, useThreadsAccessesDestroy } from "@/features/api/gen";
import useDeleteThreadAccess from "@/features/message/use-delete-thread-access";
import { MailboxRoleChoices, ThreadAccessRoleChoices } from "@/features/api/gen";
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
type ThreadActionBarProps = {
@@ -21,13 +22,13 @@ type ThreadActionBarProps = {
export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarProps) => {
const { t } = useTranslation();
const { selectedMailbox, selectedThread, unselectThread, invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { selectedMailbox, selectedThread, unselectThread } = useMailboxContext();
const { markAsReadAt } = useRead();
const { markAsTrashed, markAsUntrashed } = useTrash();
const { markAsArchived, markAsUnarchived } = useArchive();
const { markAsSpam, markAsNotSpam } = useSpam();
const { markAsStarred, markAsUnstarred } = useStarred();
const { mutate: removeThreadAccess } = useThreadsAccessesDestroy();
const { deleteThreadAccess } = useDeleteThreadAccess();
const modals = useModals();
// Full edit rights on the thread — gates archive, spam, delete.
// Star and "mark as unread" remain visible because they are personal
@@ -52,19 +53,13 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
),
});
if (decision !== 'delete') return;
removeThreadAccess({
id: mailboxAccess.id,
deleteThreadAccess({
accessId: mailboxAccess.id,
accessMailboxId: mailboxAccess.mailbox.id,
threadId: selectedThread.id,
}, {
onSuccess: () => {
addToast(<ToasterItem><p>{t('You left the thread')}</p></ToasterItem>);
invalidateThreadMessages({
type: 'delete',
metadata: { threadIds: [selectedThread.id] },
});
invalidateThreadsStats();
unselectThread();
}
},
});
};
@@ -184,7 +179,14 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
{
label: t('Mark as unread'),
icon: <Icon name="mark_email_unread" type={IconType.OUTLINED} />,
callback: () => markAsReadAt({ threadIds: [selectedThread!.id], readAt: null, onSuccess: unselectThread })
// Unmount the thread view before firing the mutation. If we waited for
// onSuccess, the cache patch would re-flag visible messages as unread
// while the view is still mounted, letting the visibility observer
// debounce a mark-as-read request that silently reverts this action.
callback: () => {
unselectThread();
markAsReadAt({ threadIds: [selectedThread!.id], readAt: null });
},
},
...(canLeaveThread ? [{
label: t('Leave this thread'),
@@ -25,7 +25,7 @@ export const ThreadMessage = forwardRef<HTMLSpanElement, ThreadMessageProps>(
const { t } = useTranslation();
const replyFormRef = useRef<HTMLDivElement>(null);
const threadViewContext = useThreadViewContext();
const { selectedMailbox, selectedThread, queryStates, invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { selectedMailbox, selectedThread, queryStates, invalidateMailbox, invalidateThreadsStats } = useMailboxContext();
const config = useConfig();
const shouldSkipDelivery = !message.is_sender || message.is_draft || message.is_trashed;
@@ -156,11 +156,11 @@ export const ThreadMessage = forwardRef<HTMLSpanElement, ThreadMessageProps>(
updateDeliveryStatus({ id: message.id, data }, {
onSuccess: () => {
invalidateThreadMessages();
invalidateMailbox();
invalidateThreadsStats();
}
});
}, [message.id, failedRecipients, updateDeliveryStatus, invalidateThreadMessages, invalidateThreadsStats]);
}, [message.id, failedRecipients, updateDeliveryStatus, invalidateMailbox, invalidateThreadsStats]);
const handleRetryFailures = useCallback(() => {
// Build the payload with recipient IDs mapped to 'retry' status
@@ -171,11 +171,11 @@ export const ThreadMessage = forwardRef<HTMLSpanElement, ThreadMessageProps>(
updateDeliveryStatus({ id: message.id, data }, {
onSuccess: () => {
invalidateThreadMessages();
invalidateMailbox();
invalidateThreadsStats();
}
});
}, [message.id, failedRecipients, updateDeliveryStatus, invalidateThreadMessages, invalidateThreadsStats]);
}, [message.id, failedRecipients, updateDeliveryStatus, invalidateMailbox, invalidateThreadsStats]);
const handleCancelRetries = useCallback(() => {
// Build the payload with recipient IDs mapped to 'cancelled' status
@@ -186,11 +186,11 @@ export const ThreadMessage = forwardRef<HTMLSpanElement, ThreadMessageProps>(
updateDeliveryStatus({ id: message.id, data }, {
onSuccess: () => {
invalidateThreadMessages();
invalidateMailbox();
invalidateThreadsStats();
}
});
}, [message.id, retryRecipients, updateDeliveryStatus, invalidateThreadMessages, invalidateThreadsStats]);
}, [message.id, retryRecipients, updateDeliveryStatus, invalidateMailbox, invalidateThreadsStats]);
// Handler for individual recipient status updates
const handleUpdateRecipientStatus = useCallback((recipientId: string, status: 'cancelled' | 'retry') => {
@@ -198,11 +198,11 @@ export const ThreadMessage = forwardRef<HTMLSpanElement, ThreadMessageProps>(
updateDeliveryStatus({ id: message.id, data }, {
onSuccess: () => {
invalidateThreadMessages();
invalidateMailbox();
invalidateThreadsStats();
}
});
}, [message.id, updateDeliveryStatus, invalidateThreadMessages, invalidateThreadsStats]);
}, [message.id, updateDeliveryStatus, invalidateMailbox, invalidateThreadsStats]);
// Effects
useEffect(() => {
@@ -57,9 +57,12 @@ const ThreadMessageActions = ({
const toggleReadStateFrom = useCallback((is_unread: boolean) => {
if (!selectedThread) return;
if (is_unread) {
// Mark as unread from here: subtract 1ms so this message becomes unread
// Mark as unread from here: subtract 1ms so this message becomes unread.
// Unmount the thread view before the mutation so the visibility observer
// cannot debounce a mark-as-read request on the newly-unread messages.
const readAt = new Date(new Date(message.created_at!).getTime() - 1).toISOString();
markAsReadAt({ threadIds: [selectedThread.id], readAt, onSuccess: unselectThread });
unselectThread();
markAsReadAt({ threadIds: [selectedThread.id], readAt });
} else {
// Mark as read from here: read up to this message's created_at
markAsReadAt({ threadIds: [selectedThread.id], readAt: message.created_at! });
@@ -0,0 +1,42 @@
import { ThreadLabel, useLabelsAddThreadsCreate } from "@/features/api/gen";
import { useMailboxContext } from "@/features/providers/mailbox";
type AddLabelOptions = {
label: ThreadLabel;
threadIds: string[];
onSuccess?: () => void;
}
/**
* Hook to add a label to one or more threads.
*
* Patch the cached threads BEFORE invalidating: a pinned thread (e.g. one
* marked-as-read while viewing "unread") is filtered out by the server on the
* next refetch, and `mergePinnedThreads` would re-insert the cached version.
* Without the local patch, that cached version still carries the old label
* list — the new label never shows up visually.
*/
const useAddLabel = () => {
const { invalidateMailbox, pinThreads } = useMailboxContext();
const { mutate, status } = useLabelsAddThreadsCreate();
const addLabel = ({ label, threadIds, onSuccess }: AddLabelOptions) => {
mutate({
id: label.id,
data: { thread_ids: threadIds },
}, {
onSuccess: () => {
pinThreads(threadIds, (thread) => {
if (thread.labels.some((l) => l.id === label.id)) return thread;
return { ...thread, labels: [...thread.labels, label] };
});
invalidateMailbox();
onSuccess?.();
}
});
};
return { addLabel, status };
};
export default useAddLabel;
@@ -2,14 +2,19 @@ import { useMailboxContext } from "../providers/mailbox";
import { useTranslation } from "react-i18next";
import useFlag from "./use-flag";
type UseArchiveOptions = {
showToast?: boolean;
}
/**
* Hook to mark messages or threads as archived
*/
const useArchive = () => {
const useArchive = (options?: UseArchiveOptions) => {
const { t } = useTranslation();
const { invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { invalidateMailbox, invalidateThreadsStats, unpinThreads } = useMailboxContext();
const { mark, unmark, status } = useFlag('archived', {
showToast: options?.showToast,
toastMessages: {
thread: (updatedCount, submittedCount) => {
if (updatedCount === 0) return t('No thread could be archived.');
@@ -23,10 +28,8 @@ const useArchive = () => {
},
},
onSuccess: (data) => {
invalidateThreadMessages({
type: 'update',
metadata: { threadIds: data.thread_ids, ids: data.message_ids },
});
unpinThreads(data.thread_ids ?? []);
invalidateMailbox();
invalidateThreadsStats();
}
});
@@ -0,0 +1,52 @@
import { useLabelsRemoveThreadsCreate } from "@/features/api/gen";
import { useMailboxContext } from "@/features/providers/mailbox";
import { useSearchParams } from "next/navigation";
type DeleteLabelOptions = {
labelId: string;
labelSlug: string;
threadIds: string[];
onSuccess?: () => void;
}
/**
* Hook to remove a label from one or more threads.
*
* Two regimes depending on whether the active view filters by the affected
* slug:
* - filter targets the slug: drop any pin BEFORE invalidating so
* `mergePinnedThreads` does not re-insert the now-excluded thread (a
* thread previously marked-as-read or starred in this view is pinned).
* - filter does not target the slug: patch the cached labels list in place.
* Without this patch, a pinned thread filtered out on the next refetch
* would be re-inserted from cache with its stale label still attached.
*/
const useDeleteLabel = () => {
const { invalidateMailbox, unpinThreads, pinThreads } = useMailboxContext();
const searchParams = useSearchParams();
const { mutate, status } = useLabelsRemoveThreadsCreate();
const deleteLabel = ({ labelId, labelSlug, threadIds, onSuccess }: DeleteLabelOptions) => {
mutate({
id: labelId,
data: { thread_ids: threadIds },
}, {
onSuccess: () => {
if (searchParams.get('label_slug') === labelSlug) {
unpinThreads(threadIds);
} else {
pinThreads(threadIds, (thread) => ({
...thread,
labels: thread.labels.filter((l) => l.id !== labelId),
}));
}
invalidateMailbox();
onSuccess?.();
}
});
};
return { deleteLabel, status };
};
export default useDeleteLabel;
@@ -0,0 +1,43 @@
import { useThreadsAccessesDestroy } from "@/features/api/gen";
import { useMailboxContext } from "@/features/providers/mailbox";
type DeleteThreadAccessOptions = {
accessId: string;
accessMailboxId: string;
threadId: string;
onSuccess?: () => void;
}
/**
* Hook to remove a ThreadAccess.
*
* On self-removal (the access belongs to the currently selected mailbox), the
* thread vanishes from the user's view: drop any pin on it BEFORE invalidating
* so `mergePinnedThreads` does not re-insert it on the next refetch (e.g. when
* the thread had been pinned earlier in this view by mark-as-read or star).
*/
const useDeleteThreadAccess = () => {
const { selectedMailbox, invalidateMailbox, invalidateThreadsStats, unpinThreads, unselectThread } = useMailboxContext();
const { mutate, status } = useThreadsAccessesDestroy();
const deleteThreadAccess = ({ accessId, accessMailboxId, threadId, onSuccess }: DeleteThreadAccessOptions) => {
const isSelfRemoval = accessMailboxId === selectedMailbox?.id;
mutate({ id: accessId, threadId }, {
onSuccess: () => {
if (isSelfRemoval) {
unpinThreads([threadId]);
}
invalidateMailbox();
if (isSelfRemoval) {
invalidateThreadsStats();
unselectThread();
}
onSuccess?.();
}
});
};
return { deleteThreadAccess, status };
};
export default useDeleteThreadAccess;
+13 -9
View File
@@ -4,18 +4,20 @@ import { addToast, ToasterItem } from "../ui/components/toaster";
import { toast, ToastContentProps } from "react-toastify";
import { useTranslation } from "react-i18next";
type FlagOnSuccess = (data: ChangeFlagRequestRequest, updatedCount?: number) => void;
type MarkAsOptions = {
threadIds?: Thread["id"][],
messageIds?: Message['id'][],
mailboxId?: string,
readAt?: string | null,
starredAt?: string | null,
onSuccess?: (data: ChangeFlagRequestRequest) => void,
onSuccess?: FlagOnSuccess,
}
type FlagOptions = {
toastMessages?: FlagToastMessages;
onSuccess?: (data: ChangeFlagRequestRequest) => void;
onSuccess?: FlagOnSuccess;
showToast?: boolean;
}
@@ -24,6 +26,11 @@ type FlagToastMessages = {
message: (updatedCount: number, submittedCount: number) => string;
}
const extractUpdatedCount = (response: { data: unknown }): number | undefined => {
const data = response.data as Record<string, unknown>;
return typeof data.updated_threads === 'number' ? data.updated_threads : undefined;
};
/**
* Generic hook to update thread/message flags
* !!! Do not use this hook directly, use the specialized hooks instead !!!
@@ -34,12 +41,9 @@ const useFlag = (flag: FlagEnum, options?: FlagOptions) => {
const { mutate, status } = useFlagCreate({
mutation: {
onSuccess: (response, { data }) => {
options?.onSuccess?.(data);
const updatedCount = extractUpdatedCount(response);
options?.onSuccess?.(data, updatedCount);
if (options?.showToast !== false && data.value === true) {
const responseData = response.data as Record<string, unknown>;
const updatedCount = typeof responseData.updated_threads === 'number'
? responseData.updated_threads
: undefined;
const threadIds = data.thread_ids ?? [];
const type = updatedCount === undefined ? 'success'
: updatedCount === 0 ? 'error'
@@ -75,7 +79,7 @@ const useFlag = (flag: FlagEnum, options?: FlagOptions) => {
...(starredAt !== undefined && { starred_at: starredAt }),
},
}, {
onSuccess: (_, { data }) => onSuccess?.(data)
onSuccess: (response, { data }) => onSuccess?.(data, extractUpdatedCount(response))
});
return {
@@ -92,7 +96,7 @@ type FlagUpdateSuccessToastProps = {
mailboxId?: Mailbox['id'];
toastId: string;
messages?: FlagToastMessages;
onUndo?: (data: ChangeFlagRequestRequest) => void;
onUndo?: FlagOnSuccess;
updatedCount?: number;
}
const FlagUpdateSuccessToast = ({ flag, threadIds = [], messageIds = [], mailboxId, toastId, messages, onUndo, updatedCount, closeToast }: FlagUpdateSuccessToastProps & Partial<ToastContentProps>) => {
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getMailboxThreadsListQueryKeyPrefix, useMailboxContext } from "@/features/providers/mailbox";
import { useMailboxContext } from "@/features/providers/mailbox";
import { getMailboxThreadsListQueryKeyPrefix } from "@/features/providers/mailbox-cache";
import { threadsEventsReadMentionPartialUpdate } from "@/features/api/gen/thread-events/thread-events";
type UseMentionReadReturn = {
+66 -23
View File
@@ -7,6 +7,28 @@ type MarkAsReadAtOptions = {
onSuccess?: () => void;
}
/**
* Compute `is_unread` for a single message given a read pointer. Mirrors the
* backend invariant `is_unread = created_at > read_at`, with `read_at === null`
* meaning "everything unread".
*/
const deriveMessageIsUnread = (createdAt: string, readAt: string | null): boolean => {
if (readAt === null) return true;
return new Date(createdAt) > new Date(readAt);
};
/**
* Compute `has_unread` for a thread given the new read pointer of its access.
* A thread is unread if any of its messages were sent strictly after the
* read pointer; with `readAt === null` (mark all unread), it is always unread
* unless the thread carries no message activity at all.
*/
const deriveThreadHasUnread = (messagedAt: string | null | undefined, readAt: string | null): boolean => {
if (!messagedAt) return false;
if (readAt === null) return true;
return new Date(messagedAt) > new Date(readAt);
};
/**
* Hook to mark threads as read up to a given timestamp.
*
@@ -14,39 +36,60 @@ type MarkAsReadAtOptions = {
* - readAt = null → all messages are unread
*
* The flag API value is derived: readAt === null means unread (value=true).
*
* The cache patch + stats invalidation live on the `useFlag` mutation-level
* callback — NOT on a per-call `onSuccess` — so they still fire when the
* caller component (e.g. `ThreadActionBar`) unmounts before the mutation
* settles. React Query drops per-call callbacks of unmounted hooks but keeps
* mutation-level ones; routing both through `useFlag` options makes the
* "mark as unread" flow survive the synchronous `unselectThread()` that
* precedes it.
*/
const useRead = () => {
const { selectedMailbox, invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { selectedMailbox, pinThreads, patchMessages, invalidateThreadsStats } = useMailboxContext();
const mailboxId = selectedMailbox?.id;
const { mark, unmark, status } = useFlag('unread', {
showToast: false,
});
onSuccess: (data) => {
const newReadAt = data.read_at ?? null;
const affectedThreadIds = data.thread_ids ?? [];
const targetMailboxId = data.mailbox_id;
const mailboxId = selectedMailbox?.id;
// Patch + pin every affected thread in the threads list cache.
// Pinning keeps it visible even when the active filter (e.g.
// "unread") would otherwise drop it on the next refetch.
if (targetMailboxId) {
pinThreads(affectedThreadIds, (thread) => ({
...thread,
has_unread: deriveThreadHasUnread(thread.messaged_at, newReadAt),
accesses: thread.accesses.map((access) =>
access.mailbox.id === targetMailboxId
? { ...access, read_at: newReadAt }
: access
),
}));
}
// Patch the per-thread messages cache so already-loaded thread
// views pick up the new read state without waiting for a refetch.
affectedThreadIds.forEach((threadId) => {
patchMessages(threadId, (message) => ({
...message,
is_unread: deriveMessageIsUnread(message.created_at!, newReadAt),
}));
});
invalidateThreadsStats();
},
});
const markAsReadAt = ({ threadIds, readAt, onSuccess }: MarkAsReadAtOptions) => {
const isUnread = readAt === null;
const flagFn = isUnread ? mark : unmark;
flagFn({
threadIds,
mailboxId,
readAt,
onSuccess: (data) => {
invalidateThreadMessages({
type: 'update',
metadata: { ids: [], threadIds: data.thread_ids ?? [] },
payload: { is_unread: isUnread },
threadAccessReadAt: mailboxId
? { mailboxId, readAt: data.read_at ?? null }
: undefined,
readAt: data.read_at ?? null,
skipThreadsRefetch: true,
});
invalidateThreadsStats();
onSuccess?.();
},
});
// Caller-supplied `onSuccess` stays per-call: it is UX-only (e.g.
// closing a modal) and acceptable to drop on unmount.
flagFn({ threadIds, mailboxId, readAt, onSuccess: () => onSuccess?.() });
};
return {
@@ -7,7 +7,7 @@ import { useMailboxContext } from "../providers/mailbox";
*/
const useSpam = () => {
const { t } = useTranslation();
const { invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { invalidateMailbox, invalidateThreadsStats, unpinThreads } = useMailboxContext();
const { mark, unmark, status } = useFlag('spam', {
toastMessages: {
thread: (updatedCount, submittedCount) => {
@@ -22,10 +22,8 @@ const useSpam = () => {
},
},
onSuccess: (data) => {
invalidateThreadMessages({
type: 'update',
metadata: { threadIds: data.thread_ids, ids: data.message_ids },
});
unpinThreads(data.thread_ids ?? []);
invalidateMailbox();
invalidateThreadsStats();
},
});
@@ -13,7 +13,7 @@ import { handle } from "../utils/errors";
const useSplitThread = () => {
const { t } = useTranslation();
const router = useRouter();
const { selectedMailbox, invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { selectedMailbox, invalidateMailbox, invalidateThreadsStats } = useMailboxContext();
const { mutateAsync, status } = useThreadsSplitCreate();
const splitThread = useCallback(async ({ threadId, messageId }: { threadId: string; messageId: string }) => {
@@ -23,7 +23,7 @@ const useSplitThread = () => {
data: { message_id: messageId },
}) as threadsSplitCreateResponse201;
await invalidateThreadMessages();
await invalidateMailbox();
await invalidateThreadsStats();
// Navigate to the new thread
@@ -40,7 +40,7 @@ const useSplitThread = () => {
} catch (error) {
handle(error);
}
}, [mutateAsync, invalidateThreadMessages, invalidateThreadsStats, selectedMailbox, router, t]);
}, [mutateAsync, invalidateMailbox, invalidateThreadsStats, selectedMailbox, router, t]);
return { splitThread, status };
};
@@ -14,7 +14,7 @@ type MarkAsStarredOptions = {
*/
const useStarred = () => {
const { t } = useTranslation();
const { selectedMailbox, invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { selectedMailbox, pinThreads, invalidateThreadsStats } = useMailboxContext();
const mailboxId = selectedMailbox?.id;
const { mark, unmark, status } = useFlag('starred', {
@@ -32,15 +32,24 @@ const useStarred = () => {
},
onSuccess: (data) => {
const starredAt = data.value ? (data.starred_at ?? new Date().toISOString()) : null;
invalidateThreadMessages({
type: 'update',
metadata: { ids: [], threadIds: data.thread_ids ?? [] },
payload: {},
threadAccessStarredAt: mailboxId
? { mailboxId, starredAt }
: undefined,
skipThreadsRefetch: true,
});
const affectedThreadIds = data.thread_ids ?? [];
const targetMailboxId = data.mailbox_id;
// Patch + pin so a thread starred under the "starred" filter stays
// visible (and conversely an unstarred one stays out instead of
// ghost-reappearing on the next refetch).
if (targetMailboxId) {
pinThreads(affectedThreadIds, (thread) => ({
...thread,
has_starred: starredAt !== null,
accesses: thread.accesses.map((access) =>
access.mailbox.id === targetMailboxId
? { ...access, starred_at: starredAt }
: access
),
}));
}
invalidateThreadsStats();
},
});
@@ -7,7 +7,7 @@ import useFlag from "./use-flag";
*/
const useTrash = () => {
const { t } = useTranslation();
const { invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { invalidateMailbox, invalidateThreadsStats, unpinThreads } = useMailboxContext();
const { mark, unmark, status } = useFlag('trashed', {
toastMessages: {
@@ -23,10 +23,8 @@ const useTrash = () => {
},
},
onSuccess: (data) => {
invalidateThreadMessages({
type: 'update',
metadata: { threadIds: data.thread_ids, ids: data.message_ids },
});
unpinThreads(data.thread_ids ?? []);
invalidateMailbox();
invalidateThreadsStats();
}
});
@@ -0,0 +1,507 @@
import { describe, expect, it } from "vitest";
import { QueryClient, type InfiniteData } from "@tanstack/react-query";
import type { Message, Thread } from "../api/gen";
import type { messagesListResponse200 } from "../api/gen/messages/messages";
import type { threadsListResponse } from "../api/gen/threads/threads";
import {
getMailboxThreadsListQueryKeyPrefix,
mergePinnedThreads,
patchMessagesInCache,
patchThreadsInCache,
removeMessagesFromCache,
trimTrailingEmptyPages,
} from "./mailbox-cache";
// Test-only relaxation of the Thread / Message types: tests construct minimal
// shapes carrying only the fields they assert on. Using `as unknown as` once
// here avoids polluting every fixture with type assertions.
type MockThread = Pick<Thread, 'id'> & Partial<Thread>;
type MockMessage = Pick<Message, 'id' | 'created_at'> & Partial<Message>;
const makeThread = (id: string, overrides: Partial<Thread> = {}): Thread =>
({ id, ...overrides } as MockThread) as unknown as Thread;
const makeMessage = (
id: string,
createdAt: string,
overrides: Partial<Message> = {},
): Message =>
({
id,
created_at: createdAt,
is_unread: false,
is_trashed: false,
is_archived: false,
thread_id: 't1',
...overrides,
} as MockMessage) as unknown as Message;
const makePage = (threads: Thread[], count?: number): threadsListResponse => ({
data: {
results: threads,
count: count ?? threads.length,
next: null,
previous: null,
},
status: 200,
headers: new Headers(),
});
const makeInfinite = (
pages: threadsListResponse[],
): InfiniteData<threadsListResponse> => ({
pages,
pageParams: pages.map((_, i) => i + 1),
});
const flatten = (data: InfiniteData<threadsListResponse>): string[] =>
data.pages.flatMap(p => p.data.results.map(t => t.id));
describe("mergePinnedThreads", () => {
it("returns newData untouched when no pinned IDs are tracked", () => {
const oldData = makeInfinite([makePage([makeThread('A'), makeThread('B')])]);
const newData = makeInfinite([makePage([makeThread('A'), makeThread('B')])]);
const ids = new Set<string>();
const result = mergePinnedThreads(oldData, newData, ids);
expect(result).toBe(newData);
expect(ids.size).toBe(0);
});
it("returns newData untouched when oldData is undefined", () => {
const newData = makeInfinite([makePage([makeThread('A')])]);
const ids = new Set(['A']);
const result = mergePinnedThreads(undefined, newData, ids);
expect(result).toBe(newData);
});
it("re-inserts a missing pinned thread at its original index within page 0", () => {
const oldData = makeInfinite([
makePage([makeThread('A'), makeThread('B'), makeThread('C'), makeThread('D')]),
]);
const newData = makeInfinite([
makePage([makeThread('B'), makeThread('C'), makeThread('D')]),
]);
const ids = new Set(['A']);
const result = mergePinnedThreads(oldData, newData, ids);
expect(flatten(result)).toEqual(['A', 'B', 'C', 'D']);
expect(ids.has('A')).toBe(true); // still protected, server did not return it
});
it("re-inserts a missing pinned thread inside the page it originally belonged to (not flattened into page 0)", () => {
const oldData = makeInfinite([
makePage([makeThread('A'), makeThread('B'), makeThread('C'), makeThread('D')]),
makePage([makeThread('E'), makeThread('F'), makeThread('G'), makeThread('H')]),
]);
// Server filtered out F (pinned after a read) from page 1
const newData = makeInfinite([
makePage([makeThread('A'), makeThread('B'), makeThread('C'), makeThread('D')]),
makePage([makeThread('E'), makeThread('G'), makeThread('H')]),
]);
const ids = new Set(['F']);
const result = mergePinnedThreads(oldData, newData, ids);
expect(result.pages).toHaveLength(2);
expect(result.pages[0].data.results.map(t => t.id)).toEqual(['A', 'B', 'C', 'D']);
expect(result.pages[1].data.results.map(t => t.id)).toEqual(['E', 'F', 'G', 'H']);
});
it("never produces duplicates across pages when flattened (regression for Bug 2)", () => {
const oldData = makeInfinite([
makePage([makeThread('A'), makeThread('B')]),
makePage([makeThread('C'), makeThread('D')]),
]);
const newData = makeInfinite([
makePage([makeThread('B')]),
makePage([makeThread('C'), makeThread('D')]),
]);
const ids = new Set(['A']);
const result = mergePinnedThreads(oldData, newData, ids);
const flat = flatten(result);
// Each ID appears exactly once after flattening.
const counts = flat.reduce<Record<string, number>>((acc, id) => {
acc[id] = (acc[id] ?? 0) + 1;
return acc;
}, {});
expect(counts).toEqual({ A: 1, B: 1, C: 1, D: 1 });
});
it("handles multiple missing threads on multiple pages independently", () => {
const oldData = makeInfinite([
makePage([makeThread('A'), makeThread('B'), makeThread('C')]),
makePage([makeThread('D'), makeThread('E'), makeThread('F')]),
]);
// A and E were pinned and filtered out
const newData = makeInfinite([
makePage([makeThread('B'), makeThread('C')]),
makePage([makeThread('D'), makeThread('F')]),
]);
const ids = new Set(['A', 'E']);
const result = mergePinnedThreads(oldData, newData, ids);
expect(result.pages[0].data.results.map(t => t.id)).toEqual(['A', 'B', 'C']);
expect(result.pages[1].data.results.map(t => t.id)).toEqual(['D', 'E', 'F']);
});
it("keeps the original order when two pinned threads from the same page are both missing", () => {
const oldData = makeInfinite([
makePage([makeThread('A'), makeThread('B'), makeThread('C'), makeThread('D')]),
]);
const newData = makeInfinite([makePage([makeThread('C'), makeThread('D')])]);
const ids = new Set(['A', 'B']);
const result = mergePinnedThreads(oldData, newData, ids);
expect(result.pages[0].data.results.map(t => t.id)).toEqual(['A', 'B', 'C', 'D']);
});
it("inflates the count on the impacted page only", () => {
const oldData = makeInfinite([
makePage([makeThread('A'), makeThread('B')], 100),
makePage([makeThread('C'), makeThread('D')], 100),
]);
const newData = makeInfinite([
makePage([makeThread('B')], 99),
makePage([makeThread('C'), makeThread('D')], 99),
]);
const ids = new Set(['A']);
const result = mergePinnedThreads(oldData, newData, ids);
expect(result.pages[0].data.count).toBe(100); // 99 + 1 re-inserted
expect(result.pages[1].data.count).toBe(99); // untouched
});
it("does not duplicate a pinned thread the server moved to another page", () => {
// Scenario: A is pinned and lived on page 0. A refetch returns A on
// page 1 instead (server-side reordering, e.g. a new unread thread
// bumped down older ones). `mergePinnedThreads` must NOT also re-
// inject A on page 0, otherwise flattening would yield two A's.
const oldData = makeInfinite([
makePage([makeThread('A'), makeThread('B'), makeThread('C')]),
makePage([makeThread('D'), makeThread('E')]),
]);
const newData = makeInfinite([
makePage([makeThread('B'), makeThread('C'), makeThread('D')]),
makePage([makeThread('E'), makeThread('A')]),
]);
const ids = new Set(['A']);
const result = mergePinnedThreads(oldData, newData, ids);
const flat = flatten(result);
expect(flat).toEqual(['B', 'C', 'D', 'E', 'A']);
expect(flat.filter(id => id === 'A')).toHaveLength(1);
});
it("still re-injects missing pinned threads after a post-fetchNextPage refetch (regression for disappearing threads)", () => {
// Full chain: user pins A and B on page 0, scrolls to load page 1,
// then a polling-triggered refetch fires. The server (filter "unread")
// drops A and B. Both should be re-injected at their original index
// in page 0.
const oldDataAfterNextPage = makeInfinite([
makePage([makeThread('A'), makeThread('B'), makeThread('C')]),
makePage([makeThread('D'), makeThread('E')]),
]);
const refetchedData = makeInfinite([
makePage([makeThread('C')]),
makePage([makeThread('D'), makeThread('E')]),
]);
const ids = new Set(['A', 'B']);
const result = mergePinnedThreads(oldDataAfterNextPage, refetchedData, ids);
expect(result.pages[0].data.results.map(t => t.id)).toEqual(['A', 'B', 'C']);
expect(result.pages[1].data.results.map(t => t.id)).toEqual(['D', 'E']);
// Still protected — server never reconfirmed them.
expect(ids.has('A')).toBe(true);
expect(ids.has('B')).toBe(true);
});
it("does not mutate newData when there are no missing pinned threads on a page", () => {
const oldData = makeInfinite([
makePage([makeThread('A'), makeThread('B')]),
makePage([makeThread('C'), makeThread('D')]),
]);
// Only page 1 has a missing pinned thread
const newData = makeInfinite([
makePage([makeThread('A'), makeThread('B')]),
makePage([makeThread('D')]),
]);
const newPage0 = newData.pages[0];
const ids = new Set(['C']);
const result = mergePinnedThreads(oldData, newData, ids);
expect(result.pages[0]).toBe(newPage0); // page 0 reference preserved — no unnecessary copy
expect(result.pages[1].data.results.map(t => t.id)).toEqual(['C', 'D']);
});
it("returns newData by reference when no page needs re-injection (preserves InfiniteData identity for downstream selectors)", () => {
// Pinned thread 'C' is still present in newData → nothing to re-inject.
// The function must short-circuit and return newData itself so that
// React Query's structuralSharing path does not trigger spurious
// re-renders for unrelated cache writes (e.g. fetchNextPage, local
// patches that hit this same callback).
const oldData = makeInfinite([
makePage([makeThread('A'), makeThread('B'), makeThread('C')]),
]);
const newData = makeInfinite([
makePage([makeThread('A'), makeThread('B'), makeThread('C')]),
]);
const ids = new Set(['C']);
const result = mergePinnedThreads(oldData, newData, ids);
expect(result).toBe(newData);
});
});
describe("trimTrailingEmptyPages", () => {
it("returns the data untouched when no trailing page is empty", () => {
const data = makeInfinite([makePage([makeThread('A')])]);
const result = trimTrailingEmptyPages(data);
expect(result).toBe(data);
});
it("removes a trailing empty page (e.g. after a bulk trash shrinks the list)", () => {
const data = makeInfinite([
makePage([makeThread('A'), makeThread('B')]),
makePage([]),
]);
const result = trimTrailingEmptyPages(data);
expect(result.pages).toHaveLength(1);
expect(result.pageParams).toHaveLength(1);
expect(result.pages[0].data.results.map(t => t.id)).toEqual(['A', 'B']);
});
it("removes multiple trailing empty pages in one pass", () => {
const data = makeInfinite([
makePage([makeThread('A')]),
makePage([]),
makePage([]),
]);
const result = trimTrailingEmptyPages(data);
expect(result.pages).toHaveLength(1);
});
it("keeps empty pages that are not at the tail", () => {
// An empty page sandwiched between non-empty ones would be a server
// bug anyway, but the trim must not touch it (removing it would
// reshuffle page indices that `mergePinnedThreads` keys off).
const data = makeInfinite([
makePage([makeThread('A')]),
makePage([]),
makePage([makeThread('B')]),
]);
const result = trimTrailingEmptyPages(data);
expect(result).toBe(data);
});
it("always keeps at least one page even when every page is empty", () => {
const data = makeInfinite([makePage([]), makePage([])]);
const result = trimTrailingEmptyPages(data);
expect(result.pages).toHaveLength(1);
expect(result.pageParams).toHaveLength(1);
});
});
describe("patchThreadsInCache", () => {
const MAILBOX_ID = 'mb-1';
const listKey = [...getMailboxThreadsListQueryKeyPrefix(MAILBOX_ID), 'list', ''];
it("applies the patcher only to threads whose id is in the target list", () => {
const qc = new QueryClient();
qc.setQueryData(listKey, makeInfinite([makePage([
makeThread('A', { has_unread: true }),
makeThread('B', { has_unread: true }),
makeThread('C', { has_unread: true }),
])]));
patchThreadsInCache(qc, MAILBOX_ID, ['A', 'C'], (thread) => ({
...thread,
has_unread: false,
}));
const cached = qc.getQueryData<InfiniteData<threadsListResponse>>(listKey);
const results = cached!.pages[0].data.results;
expect(results.find(t => t.id === 'A')!.has_unread).toBe(false);
expect(results.find(t => t.id === 'B')!.has_unread).toBe(true);
expect(results.find(t => t.id === 'C')!.has_unread).toBe(false);
});
it("propagates the patch across every cached list variant of the mailbox (filters, search…)", () => {
const qc = new QueryClient();
const unreadKey = [...getMailboxThreadsListQueryKeyPrefix(MAILBOX_ID), 'list', 'has_unread=1'];
qc.setQueryData(listKey, makeInfinite([makePage([makeThread('A', { has_starred: false })])]));
qc.setQueryData(unreadKey, makeInfinite([makePage([makeThread('A', { has_starred: false })])]));
patchThreadsInCache(qc, MAILBOX_ID, ['A'], (t) => ({ ...t, has_starred: true }));
const fromList = qc.getQueryData<InfiniteData<threadsListResponse>>(listKey);
const fromUnread = qc.getQueryData<InfiniteData<threadsListResponse>>(unreadKey);
expect(fromList!.pages[0].data.results[0].has_starred).toBe(true);
expect(fromUnread!.pages[0].data.results[0].has_starred).toBe(true);
});
it("is a no-op when threadIds is empty", () => {
const qc = new QueryClient();
const initial = makeInfinite([makePage([makeThread('A')])]);
qc.setQueryData(listKey, initial);
patchThreadsInCache(qc, MAILBOX_ID, [], (t) => ({ ...t, has_unread: false }));
const after = qc.getQueryData<InfiniteData<threadsListResponse>>(listKey);
// Reference identity preserved — no re-render churn for an empty patch.
expect(after).toBe(initial);
});
it("does not touch other mailboxes' caches", () => {
const qc = new QueryClient();
const otherKey = [...getMailboxThreadsListQueryKeyPrefix('mb-other'), 'list', ''];
qc.setQueryData(listKey, makeInfinite([makePage([makeThread('A', { has_unread: true })])]));
qc.setQueryData(otherKey, makeInfinite([makePage([makeThread('A', { has_unread: true })])]));
patchThreadsInCache(qc, MAILBOX_ID, ['A'], (t) => ({ ...t, has_unread: false }));
const otherMailbox = qc.getQueryData<InfiniteData<threadsListResponse>>(otherKey);
expect(otherMailbox!.pages[0].data.results[0].has_unread).toBe(true);
});
it("preserves the reference of pages that contain none of the targeted thread ids", () => {
// Without per-page short-circuit, the threads-list query's custom
// structuralSharing (no default deep ref-preserving diff) would let
// these unnecessary copies propagate to selectors and trigger
// re-renders for unrelated pages. Page 1 here is untouched and must
// keep its identity.
const qc = new QueryClient();
const initial = makeInfinite([
makePage([makeThread('A', { has_unread: true })]),
makePage([makeThread('B', { has_unread: true })]),
]);
qc.setQueryData(listKey, initial);
const untouchedPage = initial.pages[1];
patchThreadsInCache(qc, MAILBOX_ID, ['A'], (t) => ({ ...t, has_unread: false }));
const after = qc.getQueryData<InfiniteData<threadsListResponse>>(listKey)!;
expect(after.pages[1]).toBe(untouchedPage);
});
it("returns the cached data by reference when no page contains a targeted thread", () => {
// Patch targets a thread id absent from the cache. The whole
// InfiniteData wrapper must keep its identity so selectors do not
// observe a phantom change.
const qc = new QueryClient();
const initial = makeInfinite([makePage([makeThread('A', { has_unread: true })])]);
qc.setQueryData(listKey, initial);
patchThreadsInCache(qc, MAILBOX_ID, ['Z'], (t) => ({ ...t, has_unread: false }));
const after = qc.getQueryData<InfiniteData<threadsListResponse>>(listKey);
expect(after).toBe(initial);
});
});
describe("patchMessagesInCache", () => {
const buildCache = (messages: Message[]): messagesListResponse200 => ({
data: messages,
status: 200,
});
it("applies the patcher to every message of the targeted thread", () => {
const qc = new QueryClient();
qc.setQueryData(['messages', 't1'], buildCache([
makeMessage('m1', '2026-01-01T00:00:00Z', { is_unread: false }),
makeMessage('m2', '2026-01-02T00:00:00Z', { is_unread: false }),
]));
patchMessagesInCache(qc, 't1', (m) => ({ ...m, is_unread: true }));
const cached = qc.getQueryData<messagesListResponse200>(['messages', 't1']);
expect(cached!.data.every(m => m.is_unread)).toBe(true);
});
it("is a no-op when the thread has no cached messages", () => {
const qc = new QueryClient();
patchMessagesInCache(qc, 't1', (m) => ({ ...m, is_unread: true }));
expect(qc.getQueryData(['messages', 't1'])).toBeUndefined();
});
it("does not touch the cache of another thread", () => {
const qc = new QueryClient();
qc.setQueryData(['messages', 't1'], buildCache([
makeMessage('m1', '2026-01-01T00:00:00Z', { is_unread: false }),
]));
qc.setQueryData(['messages', 't2'], buildCache([
makeMessage('m2', '2026-01-02T00:00:00Z', { is_unread: false }),
]));
patchMessagesInCache(qc, 't1', (m) => ({ ...m, is_unread: true }));
const t2 = qc.getQueryData<messagesListResponse200>(['messages', 't2']);
expect(t2!.data[0].is_unread).toBe(false);
});
});
describe("removeMessagesFromCache", () => {
const buildCache = (messages: Message[]): messagesListResponse200 => ({
data: messages,
status: 200,
});
it("drops the messages whose ids are listed", () => {
const qc = new QueryClient();
qc.setQueryData(['messages', 't1'], buildCache([
makeMessage('m1', '2026-01-01T00:00:00Z'),
makeMessage('m2', '2026-01-02T00:00:00Z'),
makeMessage('m3', '2026-01-03T00:00:00Z'),
]));
removeMessagesFromCache(qc, 't1', ['m1', 'm3']);
const cached = qc.getQueryData<messagesListResponse200>(['messages', 't1']);
expect(cached!.data.map(m => m.id)).toEqual(['m2']);
});
it("is a no-op when messageIds is empty", () => {
const qc = new QueryClient();
const initial = buildCache([makeMessage('m1', '2026-01-01T00:00:00Z')]);
qc.setQueryData(['messages', 't1'], initial);
removeMessagesFromCache(qc, 't1', []);
const after = qc.getQueryData<messagesListResponse200>(['messages', 't1']);
expect(after).toBe(initial);
});
it("is a no-op when the thread has no cached messages", () => {
const qc = new QueryClient();
removeMessagesFromCache(qc, 't1', ['m1']);
expect(qc.getQueryData(['messages', 't1'])).toBeUndefined();
});
});
@@ -0,0 +1,182 @@
import type { InfiniteData, QueryClient } from "@tanstack/react-query";
import type { Message, Thread } from "../api/gen";
import type { messagesListResponse200 } from "../api/gen/messages/messages";
import type { threadsListResponse } from "../api/gen/threads/threads";
export type ThreadPatcher = (thread: Thread) => Thread;
export type MessagePatcher = (message: Message) => Message;
/**
* Merge-back pinned threads that the server filtered out of a fresh response.
*
* Why this exists: a mutation may push a thread out of the active filter
* (e.g. mark-as-read while viewing "unread"). We patch the local cache and
* pin the thread so the next server refetch — which no longer returns it —
* does not make it disappear under the user's cursor.
*
* Pin lifecycle: a pin is added by `pinThreads`, dropped explicitly by
* `unpinThreads` (called by mutations that move the thread out of the view),
* and the whole set is cleared when the user changes filter or mailbox.
* When the server returns the thread on its own, the pin becomes inert —
* `mergePinnedThreads` only re-injects missing threads — so we deliberately
* do not purge "confirmed" pins here. The server is always authoritative
* for the threads it returns; the pin is just a fallback for the ones it
* filters out.
*
* Re-insertion preserves **per-page semantics**: a thread missing from page
* N is re-inserted in page N at its original index, so downstream flattening
* (`pages.flatMap(p => p.data.results)`) never yields duplicates.
*/
export const mergePinnedThreads = (
oldData: InfiniteData<threadsListResponse> | undefined,
newData: InfiniteData<threadsListResponse>,
pinnedIds: Set<string>,
): InfiniteData<threadsListResponse> => {
if (!oldData || pinnedIds.size === 0) return newData;
const newThreadIds = new Set<string>();
newData.pages.forEach(page =>
page.data.results.forEach(t => newThreadIds.add(t.id))
);
let mutated = false;
const mergedPages = newData.pages.map((newPage, pageIdx) => {
const oldPage = oldData.pages[pageIdx];
if (!oldPage) return newPage;
const missing: { index: number; thread: Thread }[] = [];
oldPage.data.results.forEach((thread, idx) => {
if (pinnedIds.has(thread.id) && !newThreadIds.has(thread.id)) {
missing.push({ index: idx, thread });
}
});
if (missing.length === 0) return newPage;
const results = [...newPage.data.results];
// Ascending index order so earlier splices do not shift later indices.
missing.sort((a, b) => a.index - b.index);
for (const { index, thread } of missing) {
results.splice(Math.min(index, results.length), 0, thread);
}
mutated = true;
return {
...newPage,
data: {
...newPage.data,
count: newPage.data.count + missing.length,
results,
},
};
});
return mutated ? { ...newData, pages: mergedPages } : newData;
};
/**
* Drop trailing empty pages from an infinite query snapshot.
*
* Why: after a bulk mutation that shrinks the list (e.g. trash 25 threads
* when only 40 were loaded across 2 pages), the server no longer has enough
* data to fill all pages the client already cached. The 404 path is converted
* by the query layer into an empty terminal page, which we remove here so
* subsequent refetches stop targeting a non-existent page.
*
* Always keeps at least one page to stay compatible with React Query's
* infinite query invariants.
*/
export const trimTrailingEmptyPages = (
data: InfiniteData<threadsListResponse>,
): InfiniteData<threadsListResponse> => {
let keep = data.pages.length;
while (keep > 1 && data.pages[keep - 1].data.results.length === 0) {
keep--;
}
if (keep === data.pages.length) return data;
return {
...data,
pages: data.pages.slice(0, keep),
pageParams: data.pageParams.slice(0, keep),
};
};
/**
* Query key prefix shared between threads list query definitions and
* cross-variant cache patches. Re-exported here so cache helpers do not
* import from the provider (which would create a circular dependency).
*/
export const getMailboxThreadsListQueryKeyPrefix = (mailboxId: string | undefined) =>
['threads', mailboxId];
/**
* Apply `patcher` to every thread whose id is in `threadIds`, across every
* cached variant of the mailbox threads list (search, filters, etc.).
*/
export const patchThreadsInCache = (
queryClient: QueryClient,
mailboxId: string | undefined,
threadIds: string[],
patcher: ThreadPatcher,
): void => {
if (threadIds.length === 0) return;
const targets = new Set(threadIds);
queryClient.setQueriesData<InfiniteData<threadsListResponse>>(
{ queryKey: getMailboxThreadsListQueryKeyPrefix(mailboxId) },
(oldData) => {
if (!oldData) return oldData;
let mutated = false;
const pages = oldData.pages.map((page) => {
if (!page.data.results.some((t) => targets.has(t.id))) return page;
mutated = true;
return {
...page,
data: {
...page.data,
results: page.data.results.map((thread) =>
targets.has(thread.id) ? patcher(thread) : thread
),
},
};
});
return mutated ? { ...oldData, pages } : oldData;
},
);
};
/**
* Apply `patcher` to every message of `threadId` in the per-thread messages
* cache. No-op when the thread has no cached messages.
*/
export const patchMessagesInCache = (
queryClient: QueryClient,
threadId: Thread['id'],
patcher: MessagePatcher,
): void => {
queryClient.setQueryData<messagesListResponse200>(
['messages', threadId],
(oldData) => {
if (!oldData?.data) return oldData;
return { ...oldData, data: oldData.data.map(patcher) };
},
);
};
/**
* Drop the messages whose ids are listed from the cache of `threadId`.
*/
export const removeMessagesFromCache = (
queryClient: QueryClient,
threadId: Thread['id'],
messageIds: Message['id'][],
): void => {
if (messageIds.length === 0) return;
const targets = new Set(messageIds);
queryClient.setQueryData<messagesListResponse200>(
['messages', threadId],
(oldData) => {
if (!oldData?.data) return oldData;
return { ...oldData, data: oldData.data.filter((m) => !targets.has(m.id)) };
},
);
};
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import type { ThreadsListParams } from "../api/gen";
import {
THREADS_LIST_NUMERIC_FILTERS,
THREADS_LIST_STRING_FILTERS,
} from "./mailbox";
describe("ThreadsListParams filter coverage", () => {
it("classifies every ThreadsListParams key (compile-time check)", () => {
// Compile-time exhaustiveness guard: if Orval regenerates `ThreadsListParams`
// with a new key, this type fails to satisfy `true` and `tsc` errors on the
// instantiation below — forcing the dev to classify the new key in one of
// the filter constants (or as an explicit param in the threads queryFn).
type Assert<T extends true> = T;
type AllThreadsListParamsCovered = Assert<
Exclude<
keyof ThreadsListParams,
| (typeof THREADS_LIST_NUMERIC_FILTERS)[number]
| (typeof THREADS_LIST_STRING_FILTERS)[number]
| "mailbox_id"
| "page"
> extends never
? true
: false
>;
// The real check is the `AllThreadsListParamsCovered` type alias
// above, evaluated by `tsc --noEmit`. This runtime case keeps the
// file in the test suite and documents intent.
const _typeCheck: AllThreadsListParamsCovered = true;
expect(_typeCheck).toBe(true);
});
});
+164 -287
View File
@@ -1,11 +1,23 @@
import { createContext, PropsWithChildren, useContext, useEffect, useMemo, useRef } from "react";
import { Mailbox, MailboxRoleChoices, Message, messagesListResponse200, PaginatedThreadList, Thread, ThreadEvent, useLabelsList, useMailboxesList, useMessagesList, useThreadsEventsList, useThreadsListInfinite, getThreadsEventsListQueryKey } from "../api/gen";
import { Mailbox, MailboxRoleChoices, Message, PaginatedThreadList, Thread, ThreadEvent, ThreadsListParams, useLabelsList, useMailboxesList, useMessagesList, useThreadsEventsList, useThreadsListInfinite, getThreadsEventsListQueryKey } from "../api/gen";
import { FetchStatus, InfiniteData, QueryStatus, RefetchOptions, useQueryClient } from "@tanstack/react-query";
import type { threadsListResponse } from "../api/gen/threads/threads";
import { useRouter } from "next/router";
import usePrevious from "@/hooks/use-previous";
import { useSearchParams } from "next/navigation";
import { MAILBOX_FOLDERS } from "../layouts/components/mailbox-panel/components/mailbox-list";
import {
getMailboxThreadsListQueryKeyPrefix,
mergePinnedThreads,
patchMessagesInCache,
patchThreadsInCache,
removeMessagesFromCache,
trimTrailingEmptyPages,
type MessagePatcher,
type ThreadPatcher,
} from "./mailbox-cache";
import { threadsList } from "../api/gen/threads/threads";
import { APIError } from "../api/api-error";
type QueryState = {
status: QueryStatus,
@@ -18,24 +30,6 @@ type PaginatedQueryState = QueryState & {
isFetchingNextPage: boolean;
}
type MessageQueryInvalidationSource = {
type: 'delete' | 'update';
metadata: { ids?: Message['id'][], threadIds?: Thread['id'][] };
payload?: Partial<Message>;
/** When updating read state, optimistically patch ThreadAccess.read_at in the threads cache. */
threadAccessReadAt?: { mailboxId: string; readAt: string | null };
/** Optimistically patch ThreadAccess.starred_at in the threads cache. */
threadAccessStarredAt?: { mailboxId: string; starredAt: string | null };
/**
* When set, only messages created at or before this timestamp
* will receive the payload update (used for read pointer).
* Messages after this date keep their current state.
*/
readAt?: string | null;
/** When true, skip the threads list refetch (rely on optimistic cache only). */
skipThreadsRefetch?: boolean;
}
export type TimelineItem =
| { type: 'message'; data: Message; created_at: string }
| { type: 'event'; data: ThreadEvent; created_at: string };
@@ -50,7 +44,24 @@ type MailboxContextType = {
selectedThread: Thread | null;
unselectThread: () => void;
loadNextThreads: () => Promise<unknown>;
invalidateThreadMessages: (source?: MessageQueryInvalidationSource) => Promise<void>;
/** Patch threads in every cached list variant of the current mailbox AND
* pin them so they survive the next refetch even when filtered out. */
pinThreads: (threadIds: Thread['id'][], patcher: ThreadPatcher) => void;
/** Drop the pin protection for the given thread ids. Use it from mutations
* that intentionally remove a thread from the current view (archive,
* spam, trash, draft delete/send) so the next refetch lets it disappear
* instead of being re-injected by the pin merge. */
unpinThreads: (threadIds: Thread['id'][]) => void;
/** Patch every message of a thread in cache. */
patchMessages: (threadId: Thread['id'], patcher: MessagePatcher) => void;
/** Remove messages from a thread's cache (e.g. draft deletion). */
removeMessages: (threadId: Thread['id'], messageIds: Message['id'][]) => void;
/** Refetch the threads list of the current mailbox (every filter variant). */
invalidateThreadList: () => Promise<void>;
/** Refetch only the messages of the currently selected thread. */
invalidateThreadMessages: () => Promise<void>;
/** Shorthand: refetch threads list + messages of the selected thread. */
invalidateMailbox: () => Promise<void>;
invalidateThreadEvents: () => Promise<void>;
invalidateThreadsStats: () => Promise<void>;
invalidateLabels: () => Promise<void>;
@@ -99,16 +110,6 @@ type ReadonlySearchParamsLike = {
toString: () => string;
};
/**
* Query key prefix for the threads LIST query of a mailbox.
*
* Used for invalidation and for prefix-matching optimistic updates
* (`setQueriesData`) that should apply to every filter variant of
* the same mailbox (list, search, all filter combinations…) in one shot.
*/
export const getMailboxThreadsListQueryKeyPrefix = (mailboxId: string | undefined) =>
['threads', mailboxId];
/**
* Query key prefix for the SEARCH subtree of a mailbox's threads list.
*
@@ -147,6 +148,32 @@ export const getMailboxThreadsListQueryKey = (
return [...prefix, hasSearch ? 'search' : 'list', normalized.toString()];
};
// Allow-list of `ThreadsListParams` keys we accept from the URL. `router.query`
// also exposes dynamic path segments (mailboxId, threadId) — spreading it raw
// would leak them as query params and bypass the typed contract.
// Exhaustiveness vs `ThreadsListParams` is enforced in `mailbox.test.ts`.
export const THREADS_LIST_NUMERIC_FILTERS = [
"has_active",
"has_archived",
"has_attachments",
"has_delivery_pending",
"has_draft",
"has_mention",
"has_messages",
"has_sender",
"has_starred",
"has_trashed",
"has_unread",
"has_unread_mention",
"is_spam",
"is_trashed",
] as const satisfies ReadonlyArray<keyof ThreadsListParams>;
export const THREADS_LIST_STRING_FILTERS = [
"label_slug",
"search",
] as const satisfies ReadonlyArray<keyof ThreadsListParams>;
const MailboxContext = createContext<MailboxContextType>({
mailboxes: null,
threads: null,
@@ -157,7 +184,13 @@ const MailboxContext = createContext<MailboxContextType>({
selectedThread: null,
loadNextThreads: async () => {},
unselectThread: () => {},
pinThreads: () => {},
unpinThreads: () => {},
patchMessages: () => {},
removeMessages: () => {},
invalidateThreadList: async () => {},
invalidateThreadMessages: async () => {},
invalidateMailbox: async () => {},
invalidateThreadEvents: async () => {},
invalidateThreadsStats: async () => {},
invalidateLabels: async () => {},
@@ -206,7 +239,7 @@ const MailboxContext = createContext<MailboxContextType>({
export const MailboxProvider = ({ children }: PropsWithChildren) => {
const queryClient = useQueryClient();
const router = useRouter();
const optimisticThreadIdsRef = useRef(new Set<string>());
const pinnedThreadIdsRef = useRef(new Set<string>());
const searchParams = useSearchParams();
const previousSearchParams = usePrevious(searchParams);
const hasSearchParamsChanged = useMemo(() => {
@@ -245,107 +278,64 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
enabled: !!selectedMailbox,
initialPageParam: 1,
queryKey: threadQueryKey,
// `fetchNextPage` must stop at the true last page of the server.
// Returning `undefined` is the React Query idiom for "no more
// pages" — without it the hook would keep asking for pages the
// backend has since dropped (bulk trash/archive shrinks the list).
getNextPageParam: (lastPage, pages) => {
if (lastPage?.data?.next === null) return undefined;
return pages.length + 1;
},
/**
* Merge-back optimistic threads on refetch.
*
* Problem: when a filter is active (e.g. "unread" or "starred"),
* a read/starred mutation optimistically patches the thread in
* cache but skips the list refetch (`skipThreadsRefetch`). Later,
* when a refetch does happen (polling, navigation…), the server
* no longer returns that thread (it no longer matches the filter)
* → it would vanish from the UI.
*
* Solution: `structuralSharing` runs *before* React re-renders.
* It compares old cache (with optimistic threads) to the new
* server response. Any thread tracked in `optimisticThreadIdsRef`
* that is missing from the server response is re-inserted at its
* original position so the user sees no flash.
*
* Lifecycle of an optimistic thread ID:
* - Added to the set by `invalidateThreadMessages({ skipThreadsRefetch })`
* - Removed from the set here when the server response includes it
* (meaning the server still considers it valid for the current query)
* - Cleared entirely when the user changes filters or mailbox
* (via the cleanup `useEffect` on `selectedMailbox?.id` / `searchParams`)
*/
structuralSharing: (oldData, newData) => {
const optimisticIds = optimisticThreadIdsRef.current;
if (!oldData || optimisticIds.size === 0) return newData;
const oldInfinite = oldData as InfiniteData<threadsListResponse>;
const newInfinite = newData as InfiniteData<threadsListResponse>;
// 1. Build flat index of old thread positions to restore ordering later
const oldOrderedIds: string[] = [];
oldInfinite.pages.forEach(page =>
page.data.results.forEach(t => oldOrderedIds.push(t.id))
);
// 2. Collect all thread IDs the server returned
const newThreadIds = new Set<string>();
newInfinite.pages.forEach(page =>
page.data.results.forEach(t => newThreadIds.add(t.id))
);
// 3. Identify optimistic threads the server filtered out,
// remembering their original flat index for position-preserving re-insertion
const missingByOldIndex = new Map<number, Thread>();
oldInfinite.pages.forEach(page =>
page.data.results.forEach(thread => {
if (optimisticIds.has(thread.id) && !newThreadIds.has(thread.id)) {
missingByOldIndex.set(oldOrderedIds.indexOf(thread.id), thread);
}
})
);
// 4. Stop protecting threads the server still returns
// (they don't need merge-back anymore)
optimisticIds.forEach(id => {
if (newThreadIds.has(id)) optimisticIds.delete(id);
});
if (missingByOldIndex.size === 0) return newData;
// 5. Flatten new server results then splice missing threads
// back at their original positions (sorted ascending so
// earlier splices don't shift later indices)
const flatNewResults: Thread[] = [];
newInfinite.pages.forEach(page =>
flatNewResults.push(...page.data.results)
);
const sortedEntries = [...missingByOldIndex.entries()].sort(([a], [b]) => a - b);
for (const [originalIndex, thread] of sortedEntries) {
const insertAt = Math.min(originalIndex, flatNewResults.length);
flatNewResults.splice(insertAt, 0, thread);
}
// 6. Return merged results in page 1
return {
...newInfinite,
pages: newInfinite.pages.map((page, i) => {
if (i !== 0) return page;
return {
...page,
data: {
...page.data,
count: page.data.count + missingByOldIndex.size,
results: flatNewResults,
},
};
}),
queryFn: async ({ signal, pageParam }) => {
const params: ThreadsListParams = {
mailbox_id: selectedMailbox?.id ?? '',
page: pageParam as number,
};
for (const key of THREADS_LIST_NUMERIC_FILTERS) {
const value = searchParams.get(key);
if (value !== null) params[key] = Number(value);
}
for (const key of THREADS_LIST_STRING_FILTERS) {
const value = searchParams.get(key);
if (value !== null) params[key] = value;
}
try {
return await threadsList(params, { signal });
} catch (error) {
// Intercept the 404 DRF raises for out-of-range pages. The list
// may legitimately shrink between two refetches (e.g. user bulk
// trashes threads), and React Query refetches every cached page
// sequentially — a raw 404 on a trailing page would fail the
// whole infinite query and flash an error toast. Convert it into
// an empty terminal page so `trimTrailingEmptyPages` in
// `structuralSharing` can drop it cleanly.
const page = typeof pageParam === 'number' ? pageParam : 1;
if (error instanceof APIError && error.code === 404 && page > 1) {
return {
status: 200,
data: {
count: 0,
results: [],
next: null,
previous: null,
} as PaginatedThreadList,
headers: new Headers(),
} as threadsListResponse;
}
throw error;
}
},
// Merge-back pinned threads filtered out by the server, then drop
// trailing empty pages left over by shrunk result sets.
structuralSharing: (oldData, newData) => {
const merged = mergePinnedThreads(
oldData as InfiniteData<threadsListResponse> | undefined,
newData as InfiniteData<threadsListResponse>,
pinnedThreadIdsRef.current,
);
return trimTrailingEmptyPages(merged);
},
},
request: {
params: {
...(router.query as Record<string, string>),
mailbox_id: selectedMailbox?.id ?? '',
}
}
});
/**
@@ -414,174 +404,55 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
});
const _updateThreadMessagesQueryData = (threadId: Thread['id'], source: MessageQueryInvalidationSource) => {
queryClient.setQueryData(['messages', threadId], (oldData: messagesListResponse200 | undefined) => {
if (!oldData?.data) return oldData;
let newResults = [ ...oldData.data ];
if (source.type === 'delete') {
newResults = newResults.filter((message: Message) => {
if ((source.metadata.threadIds ?? []).includes(threadId)) return true;
return !(source.metadata.ids ?? []).includes(message.id);
});
} else if (source.type === 'update') {
newResults = newResults.map((message: Message) => {
const isTargeted =
(source.metadata.threadIds ?? []).includes(threadId)
|| (source.metadata.ids ?? []).includes(message.id);
/**
* Patch threads in every cached list variant of the current mailbox AND
* mark them as pinned so they survive the next server refetch even when
* filtered out (e.g. mark-as-read while viewing the "unread" filter).
*
* The patcher itself encodes the domain semantics — recomputing
* `has_unread`, flipping `has_starred`, mutating `accesses`, etc. is the
* caller hook's responsibility, not the cache's.
*/
const pinThreads = (threadIds: Thread['id'][], patcher: ThreadPatcher) => {
if (threadIds.length === 0) return;
patchThreadsInCache(queryClient, selectedMailbox?.id, threadIds, patcher);
threadIds.forEach((id) => pinnedThreadIdsRef.current.add(id));
};
if (!isTargeted) return message;
/**
* Symmetric of `pinThreads`: drop the pin so the next server refetch is
* authoritative again. Mutations that move a thread out of the current
* view (archive, spam, trash, draft delete/send) call this BEFORE
* invalidating, otherwise `mergePinnedThreads` would re-insert the thread
* the server just filtered out.
*/
const unpinThreads = (threadIds: Thread['id'][]) => {
threadIds.forEach((id) => pinnedThreadIdsRef.current.delete(id));
};
// When a readAt pointer is provided, only update messages
// created at or before that timestamp. When readAt is null
// (mark all unread), update every message.
if (source.readAt !== undefined && source.readAt !== null) {
if (message.created_at > source.readAt) return message;
}
const patchMessages = (threadId: Thread['id'], patcher: MessagePatcher) => {
patchMessagesInCache(queryClient, threadId, patcher);
};
return { ...message, ...source.payload };
});
}
const removeMessages = (threadId: Thread['id'], messageIds: Message['id'][]) => {
removeMessagesFromCache(queryClient, threadId, messageIds);
};
return {...oldData, data: newResults};
const invalidateThreadList = async () => {
await queryClient.invalidateQueries({
queryKey: getMailboxThreadsListQueryKeyPrefix(selectedMailbox?.id),
});
}
/**
* Optimistically update ThreadAccess.read_at in the infinite threads cache
* so ThreadItem sees the new read state immediately without waiting for re-fetch.
*/
const _updateThreadAccessReadAt = (
threadIds: Thread['id'][],
mailboxId: string,
readAt: string | null,
) => {
queryClient.setQueriesData<InfiniteData<threadsListResponse>>(
{ queryKey: getMailboxThreadsListQueryKeyPrefix(mailboxId) },
(oldData) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
data: {
...page.data,
results: page.data.results.map((thread) => {
if (!threadIds.includes(thread.id)) return thread;
return {
...thread,
has_unread: thread.messaged_at
? (readAt === null || new Date(thread.messaged_at) > new Date(readAt))
: false,
accesses: thread.accesses.map((access) =>
access.mailbox.id === mailboxId
? { ...access, read_at: readAt }
: access
),
};
}),
},
})),
};
},
);
};
/**
* Optimistically update ThreadAccess.starred_at in the infinite threads cache
* so ThreadItem sees the new starred state immediately without waiting for re-fetch.
*/
const _updateThreadAccessStarredAt = (
threadIds: Thread['id'][],
mailboxId: string,
starredAt: string | null,
) => {
queryClient.setQueriesData<InfiniteData<threadsListResponse>>(
{ queryKey: getMailboxThreadsListQueryKeyPrefix(mailboxId) },
(oldData) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
data: {
...page.data,
results: page.data.results.map((thread) => {
if (!threadIds.includes(thread.id)) return thread;
return {
...thread,
has_starred: starredAt !== null,
accesses: thread.accesses.map((access) =>
access.mailbox.id === mailboxId
? { ...access, starred_at: starredAt }
: access
),
};
}),
},
})),
};
},
);
};
/**
* Invalidate the threads and messages queries to refresh the data
* If a source is provided, it could be used to update query cache from the source data
*/
const invalidateThreadMessages = async (source?: MessageQueryInvalidationSource) => {
// Optimistically patch caches before invalidating so the UI
// renders the correct state immediately while re-fetches are in flight.
if (source?.threadAccessReadAt) {
const affectedThreadIds = source.metadata.threadIds ?? [];
if (affectedThreadIds.length > 0) {
_updateThreadAccessReadAt(
affectedThreadIds,
source.threadAccessReadAt.mailboxId,
source.threadAccessReadAt.readAt,
);
}
}
if (source?.threadAccessStarredAt) {
const affectedThreadIds = source.metadata.threadIds ?? [];
if (affectedThreadIds.length > 0) {
_updateThreadAccessStarredAt(
affectedThreadIds,
source.threadAccessStarredAt.mailboxId,
source.threadAccessStarredAt.starredAt,
);
}
}
if (source && ((source.metadata.threadIds ?? []).length ?? 0) > 0) {
source.metadata.threadIds!.forEach(threadId => {
if (queryClient.getQueryState(['messages', threadId])) {
_updateThreadMessagesQueryData(threadId, source);
}
});
}
if (source && selectedThread && ((source.metadata.ids ?? []).length ?? 0) > 0) {
_updateThreadMessagesQueryData(selectedThread.id, source);
}
if (source?.skipThreadsRefetch) {
// Track these threads so structuralSharing merges them back on future refetches
(source.metadata.threadIds ?? []).forEach(id =>
optimisticThreadIdsRef.current.add(id)
);
} else {
// Remove affected threads from optimistic tracking since the
// server response is authoritative after a real refetch.
(source?.metadata.threadIds ?? []).forEach(id =>
optimisticThreadIdsRef.current.delete(id)
);
await queryClient.invalidateQueries({ queryKey: getMailboxThreadsListQueryKeyPrefix(selectedMailbox?.id) });
}
const invalidateThreadMessages = async () => {
if (selectedThread) {
await queryClient.invalidateQueries({ queryKey: ['messages', selectedThread.id] });
}
}
};
const invalidateMailbox = async () => {
await Promise.all([invalidateThreadList(), invalidateThreadMessages()]);
};
const invalidateThreadEvents = async () => {
if (selectedThread) {
@@ -627,7 +498,13 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
selectedThread,
unselectThread,
loadNextThreads: threadsQuery.fetchNextPage,
pinThreads,
unpinThreads,
patchMessages,
removeMessages,
invalidateThreadList,
invalidateThreadMessages,
invalidateMailbox,
invalidateThreadEvents,
invalidateThreadsStats,
invalidateLabels,
@@ -679,7 +556,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
} else {
router.replace(`/mailbox/${selectedMailbox.id}?${new URLSearchParams(defaultFolder.filter).toString()}${hash}`);
}
invalidateThreadMessages();
invalidateMailbox();
}
}
}, [selectedMailbox]);
@@ -744,10 +621,10 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
}
}, [messagesQuery.data?.data]);
// Clear optimistic thread IDs when filters or mailbox change so the next
// Clear pinned thread IDs when filters or mailbox change so the next
// refetch shows the pure server-side list.
useEffect(() => {
optimisticThreadIdsRef.current.clear();
pinnedThreadIdsRef.current.clear();
}, [selectedMailbox?.id, searchParams.toString()]);
useEffect(() => {
@@ -20,7 +20,7 @@ const SentBoxContext = createContext<SentBoxContextType>({
* toast to inform the user of the sending status.
*/
export const SentBoxProvider = ({ children }: PropsWithChildren) => {
const { invalidateThreadsStats, invalidateThreadMessages } = useMailboxContext();
const { invalidateThreadsStats, invalidateMailbox } = useMailboxContext();
const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
const addQueuedMessage = (taskId: string) => {
@@ -36,7 +36,7 @@ export const SentBoxProvider = ({ children }: PropsWithChildren) => {
const handleSettled = (taskId: string) => {
removeQueuedMessage(taskId);
invalidateThreadsStats();
invalidateThreadMessages();
invalidateMailbox();
}
const context = useMemo(
@@ -1,6 +1,6 @@
import { Badge } from "@/features/ui/components/badge"
import { ColorHelper } from "@/features/utils/color-helper"
import { ThreadLabel, useLabelsAddThreadsCreate, useLabelsRemoveThreadsCreate } from "@/features/api/gen"
import { ThreadLabel } from "@/features/api/gen"
import { useMailboxContext } from "@/features/providers/mailbox";
import { useTranslation } from "react-i18next";
import { Icon, IconSize, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
@@ -11,6 +11,8 @@ import { useMemo } from "react";
import { addToast, ToasterItem } from "../toaster";
import { toast } from "react-toastify";
import useAbility, { Abilities } from "@/hooks/use-ability";
import useDeleteLabel from "@/features/message/use-delete-label";
import useAddLabel from "@/features/message/use-add-label";
import clsx from "clsx";
type LabelBadgeProps = {
@@ -29,39 +31,39 @@ export const LabelBadge = ({ label, removable = false, linkable = false, compact
return `${pathname}?${params.toString()}`;
}, [label, pathname]);
const isActive = searchParams.get('label_slug') === label.slug;
const { invalidateThreadMessages, selectedThread, selectedMailbox } = useMailboxContext();
const { selectedThread, selectedMailbox } = useMailboxContext();
const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox);
const badgeColor = ColorHelper.getContrastColor(label.color!, { lightColor: `var(--c--globals--colors--white-850)`, darkColor: `var(--c--globals--colors--black-850)`});
const { mutate: deleteLabelMutation, isPending: isDeletingLabel } = useLabelsRemoveThreadsCreate({
mutation: {
onSuccess: (_, variables) => {
invalidateThreadMessages();
const { addLabel } = useAddLabel();
const { deleteLabel, status: deleteStatus } = useDeleteLabel();
const isDeletingLabel = deleteStatus === 'pending';
const handleDelete = () => {
if (!selectedThread?.id) return;
const toastId = JSON.stringify({ id: label.id, threadId: selectedThread.id });
deleteLabel({
labelId: label.id,
labelSlug: label.slug,
threadIds: [selectedThread.id],
onSuccess: () => {
addToast(
<ToasterItem
type="info"
actions={[{
label: t('Undo'),
onClick: () => addLabelMutation(variables)
onClick: () => {
addLabel({ label, threadIds: [selectedThread.id] });
toast.dismiss(toastId);
}
}]}
>
<span className="material-icons">label_off</span>
<span>{t('Label "{{label}}" removed from this conversation.', { label: label.name })}</span>
</ToasterItem>,
{
toastId: JSON.stringify(variables),
}
)
}
}
});
const { mutate: addLabelMutation, } = useLabelsAddThreadsCreate({
mutation: {
onSuccess: (_, variables) => {
invalidateThreadMessages();
toast.dismiss(JSON.stringify(variables));
}
}
});
{ toastId }
);
},
});
};
const showLink = linkable && !isActive;
return (
@@ -71,7 +73,7 @@ export const LabelBadge = ({ label, removable = false, linkable = false, compact
<Tooltip content={t('Delete')} placement="right">
<button
className="label-badge__remove-cta"
onClick={() => deleteLabelMutation({ id: label.id, data: { thread_ids: [selectedThread.id] } })}
onClick={handleDelete}
disabled={isDeletingLabel}
aria-busy={isDeletingLabel}
>