mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-13 03:15:34 +02:00
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>
208 lines
7.9 KiB
TypeScript
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) });
|
|
},
|
|
});
|
|
}
|