Files
multica/packages/core/inbox/mutations.ts
Jiayuan Zhang f0110da555 feat(inbox): mark a notification unread from the row context menu (MUL-5496) (#6137)
The inbox auto-marks a notification read the moment it is selected, so
"opened" and "handled" were the same signal — a row you glanced at and
meant to come back to was gone from the unread count with no way back.

Right-click any inbox row for a shared context menu: Mark as read /
Mark as unread, plus Archive (Unarchive in the archived view).

- POST /api/inbox/{id}/unread + MarkInboxUnread query, publishing
  inbox:unread. Item-scoped, mirroring mark-read: the list renders one
  row per issue carrying that group's newest item, so flipping the whole
  group would resurrect siblings the user already dealt with.
- useMarkInboxUnread patches both lists optimistically and re-pulls the
  cross-workspace unread summary on settle.
- One shared menu per list rather than a Base UI root per row (the same
  shape IssueContextMenuProvider uses): only one is ever open, and a
  per-row root would unmount with its menu when the row scrolls out of
  the virtualized viewport.
- The read toggle is main-view only — archived rows deliberately render
  as read and the unread count excludes them, so a toggle there would
  report success and change nothing on screen.
- Parking the row that is currently open holds the auto-read effect off
  that one item while it stays selected; re-opening it later marks it
  read again.
- Mobile subscribes to inbox:unread so the unread dots agree across
  clients.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 19:18:36 +08:00

208 lines
7.9 KiB
TypeScript

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { inboxKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { InboxItem } from "../types";
export function useMarkInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.markInboxRead(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.all(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
const prevArchived = qc.getQueryData<InboxItem[]>(inboxKeys.archived(wsId));
const markRead = (old: InboxItem[] | undefined) =>
old?.map((item) => (item.id === id ? { ...item, read: true } : item));
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), markRead);
// Opening a notification from the archived sub-view marks it read too —
// patch that cache as well, or its unread dot would sit there until the
// next refetch.
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), markRead);
return { prev, prevArchived };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
if (ctx?.prevArchived) qc.setQueryData(inboxKeys.archived(wsId), ctx.prevArchived);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
},
});
}
/**
* Flip a notification back to unread — the inverse of {@link useMarkInboxRead}.
*
* Same optimistic shape as marking read (predictable outcome, no navigation,
* trivial rollback), and it patches BOTH caches for the same reason: an item
* can be actioned from either list, and leaving the other one stale would show
* two different read states for one notification after a view switch.
*
* The unread badge is derived from the main list's cache (`useInboxUnreadCount`
* dedupes it client-side), so the optimistic patch raises the badge without
* waiting for the round-trip; `onSettled` re-pulls the cross-workspace summary,
* which is server-computed and cannot be patched here.
*/
export function useMarkInboxUnread() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.markInboxUnread(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.all(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
const prevArchived = qc.getQueryData<InboxItem[]>(inboxKeys.archived(wsId));
const markUnread = (old: InboxItem[] | undefined) =>
old?.map((item) => (item.id === id ? { ...item, read: false } : item));
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), markUnread);
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), markUnread);
return { prev, prevArchived };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
if (ctx?.prevArchived) qc.setQueryData(inboxKeys.archived(wsId), ctx.prevArchived);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
// The switcher dot must light again when the workspace goes back to
// having unread items — that count lives on the server.
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
},
});
}
export function useArchiveInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.archiveInbox(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
// Archive all items for the same issue (same behavior as store)
const target = prev?.find((i) => i.id === id);
const issueId = target?.issue_id;
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
item.id === id || (issueId && item.issue_id === issueId)
? { ...item, archived: true }
: item,
),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
// Both lists: the item just moved from the main inbox into the archive.
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
},
});
}
/**
* Restore an archived notification to the main inbox.
*
* Optimistic on the ARCHIVED cache only: flipping `archived` there makes the
* row leave the archived list at once (the dedup helper filters on it), the
* user stays put, and rollback is a single snapshot restore. The main list is
* left to `onSettled` — its contents after a restore are the server's call
* (which sibling rows come back, their read state, their order), so it is
* invalidated rather than reconstructed client-side.
*/
export function useUnarchiveInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.unarchiveInbox(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.archived(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.archived(wsId));
// Restore every sibling for the same issue — the server unarchives the
// whole issue group, so the optimistic patch must too or the rest of the
// group would linger in the archived list until the refetch lands.
const target = prev?.find((i) => i.id === id);
const issueId = target?.issue_id;
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), (old) =>
old?.map((item) =>
item.id === id || (issueId && item.issue_id === issueId)
? { ...item, archived: false }
: item,
),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.archived(wsId), ctx.prev);
},
onSettled: () => {
// Both lists: the item moves from one to the other, and the unread badge
// rises again when it was archived unread.
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
},
});
}
export function useMarkAllInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.markAllInboxRead(),
onMutate: async () => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
!item.archived ? { ...item, read: true } : item,
),
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
// The three batch-archive mutations below all move items into the archive, so
// each invalidates BOTH lists on settle.
export function useArchiveAllInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
},
});
}
export function useArchiveAllReadInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllReadInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
},
});
}
export function useArchiveCompletedInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveCompletedInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
},
});
}