mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 14:09:22 +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>
74 lines
2.7 KiB
TypeScript
74 lines
2.7 KiB
TypeScript
/**
|
|
* Inbox realtime — Layer 3 of the realtime stack.
|
|
*
|
|
* Two subscription groups:
|
|
*
|
|
* 1. `inbox:*` events → invalidate the inbox query. inbox payloads are
|
|
* small and (apart from inbox:new) rare, so refetching is cheaper than
|
|
* maintaining per-event patchers. Multi-device parity: subscribing to
|
|
* inbox:read / inbox:archived means a read/archive on web reaches
|
|
* mobile within the next WS frame (web's use-realtime-sync deliberately
|
|
* DOESN'T subscribe to those, but mobile's stricter freshness wins for
|
|
* multi-device users).
|
|
*
|
|
* 2. `issue:*` events → patch the inbox cache directly via the dedicated
|
|
* updaters (inbox-ws-updaters.ts). Required because:
|
|
* - `issue:updated` with a new status must flip the inbox row's
|
|
* StatusIcon inline — otherwise the row keeps showing stale status.
|
|
* - `issue:deleted` must strip every inbox item pointing at that
|
|
* issue, otherwise tapping the orphan row 404s on issue/[id].
|
|
* Web does the same in `packages/core/inbox/ws-updaters.ts`.
|
|
*
|
|
* Reconnect: invalidate the list (we may have missed events while down;
|
|
* no replay buffer in v1).
|
|
*/
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { inboxKeys } from "@/data/queries/inbox";
|
|
import { useWSSubscriptions } from "@/lib/use-ws-subscriptions";
|
|
import {
|
|
dropInboxItemsByIssue,
|
|
patchInboxIssueStatus,
|
|
} from "./inbox-ws-updaters";
|
|
|
|
export function useInboxRealtime() {
|
|
const qc = useQueryClient();
|
|
|
|
useWSSubscriptions(
|
|
(ws, wsId) => {
|
|
const invalidate = () =>
|
|
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
|
|
|
return [
|
|
// Inbox-domain events: refetch the small inbox list.
|
|
ws.on("inbox:new", invalidate),
|
|
ws.on("inbox:read", invalidate),
|
|
ws.on("inbox:archived", invalidate),
|
|
// Mobile has no archived view yet (web/desktop only, MUL-3736), but an
|
|
// unarchive there restores the item to THIS list — without refetching,
|
|
// mobile keeps showing the pre-restore list.
|
|
ws.on("inbox:unarchived", invalidate),
|
|
ws.on("inbox:batch-read", invalidate),
|
|
ws.on("inbox:batch-archived", invalidate),
|
|
|
|
// Cross-cutting: issue events that need to patch inbox state.
|
|
ws.on("issue:updated", (payload) => {
|
|
patchInboxIssueStatus(
|
|
qc,
|
|
wsId,
|
|
payload.issue.id,
|
|
payload.issue.status,
|
|
);
|
|
}),
|
|
ws.on("issue:deleted", (payload) => {
|
|
dropInboxItemsByIssue(qc, wsId, payload.issue_id);
|
|
}),
|
|
|
|
// After a reconnect we don't know what we missed during the
|
|
// downtime — refresh from server.
|
|
ws.onReconnect(invalidate),
|
|
];
|
|
},
|
|
[qc],
|
|
);
|
|
}
|