Files
multica/packages/core/inbox/mutations.ts
Naiyuan Qing 6b2097ccbb feat(inbox): archived notifications sub-view (MUL-3736) (#5518)
Adds an "Archived" sub-view to the Inbox, reachable from an entry at the
bottom of the main list, with per-row unarchive. Mirrors chat's archived
sub-view so the two surfaces share one mental model.

Backend:
- GET /api/inbox/archived and POST /api/inbox/{id}/unarchive. Kept off the
  existing GET /api/inbox so installed clients keep their contract and the
  unbounded archive never rides along with the main list.
- The archived query excludes any issue that still has an active row. Archiving
  is issue-level, so a new notification on an archived issue leaves old archived
  rows beside a fresh active one — without the guard the issue renders in BOTH
  lists. The exclusion lives in SQL so neither list depends on the other's cache.
- Unarchive is issue-level (mirroring archive) and leaves `read` untouched, so a
  restored unread item raises the unread badge again.
- v1 ships no pagination: LIMIT 200, newest-first, so truncation drops the
  oldest rows and never hides a group's newest one.
- inbox:unarchived event, fanned out to the recipient like the other personal
  inbox events.
- Two CONCURRENTLY-built indexes; inbox_item previously had none covering
  workspace/archived/created_at.

Frontend:
- Separate TanStack cache per list; every inbox event invalidates the workspace
  prefix, since any of them can move an item across the boundary.
- View persisted as ?view=archived, so refresh, back/forward, and the mobile
  detail-back all return to the list the user was in.
- Batch actions stay main-view only — they archive from the MAIN inbox, so
  offering them over the archived list would do the opposite of what it reads.
- Mobile subscribes to inbox:unarchived (its list gains the restored row); its
  own archived view remains follow-up.

Known debt: no pagination, so an archive past ~200 rows is truncated silently
in the UI; the entry's count is the deduplicated count of the rows returned.

Verified: pnpm typecheck/test/lint (0 errors), go build/vet, Go inbox suite
against a real Postgres, migrations up+down, and EXPLAIN confirming both new
indexes serve the query.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 14:58:42 +08:00

167 lines
6.0 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) });
},
});
}
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) });
},
});
}