mirror of
https://github.com/suitenumerique/messages.git
synced 2026-09-30 21:55:00 +02:00
♻️(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:
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+2
-2
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+43
-55
@@ -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);
|
||||
};
|
||||
|
||||
+10
-8
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
}] : []),
|
||||
|
||||
+11
-16
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+16
-14
@@ -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'),
|
||||
|
||||
+9
-9
@@ -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(() => {
|
||||
|
||||
+5
-2
@@ -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;
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user