mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
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>
167 lines
6.0 KiB
TypeScript
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) });
|
|
},
|
|
});
|
|
}
|