feat(mobile): inbox refactor — Mark all read, swipe UX, parity fixes

Swipe-to-archive no longer auto-fires on full drag (felt aggressive, no
peek, easy mistrigger on fast scroll). Now matches iOS Mail / Linear: drag
reveals the red Archive button + medium haptic at threshold, user taps to
commit. Auto-fire path removed; useAnimatedReaction + runOnJS bridges the
UI-thread shared value to Haptics.impactAsync.

Behavioral parity fixes the previous mobile inbox was missing vs web:
  - Mark all read action — endpoint POST /api/inbox/mark-all-read already
    existed server-side; mobile just never wired it. Added api.markAllInbox
    Read + useMarkAllInboxRead (optimistic flip read=true on non-archived)
    + ActionSheet menu entry as the first option.
  - issue:updated → patch inbox row's StatusIcon inline. Previously mobile
    ignored the event and showed stale status until the next inbox event
    refetched the list.
  - issue:deleted → strip orphaned inbox rows so tapping doesn't 404 on
    the issue detail page.
  - Both via a new mobile-owned inbox-ws-updaters.ts mirroring web's
    packages/core/inbox/ws-updaters.ts.

Internal cleanup:
  - inboxKeys factory in data/queries/inbox.ts ({all,list}, 3-segment
    shape matching web). 6 inline ["inbox", wsId] strings retired across
    queries / mutations / realtime / useCreateIssue inbox invalidate.
  - Synchronous setQueryData hack (workaround for iOS push transition
    snapshot capturing pre-flip state) moved from inbox.tsx caller into
    useMarkInboxRead.onMutate. Every caller benefits, none can forget it.

UX polish:
  - Loading state: 6 Skeleton rows (RNR, installed this PR) replacing
    centered ActivityIndicator.
  - Empty state: mail-open icon + helper text replacing bare "No inbox
    items." copy.
  - ItemSeparatorComponent ml-[60px] → ml-16 (token, aligns with avatar
    36 + px-4 + gap-3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-19 15:08:07 +08:00
parent f5dbcad533
commit ace56f80c8
9 changed files with 263 additions and 85 deletions

View File

@@ -1,16 +1,17 @@
import { useMemo } from "react";
import {
ActionSheetIOS,
ActivityIndicator,
Alert,
FlatList,
View,
} from "react-native";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { InboxItem } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Header } from "@/components/ui/header";
import { IconButton } from "@/components/ui/icon-button";
import { HeaderActions } from "@/components/ui/app-header-actions";
@@ -21,15 +22,18 @@ import {
useArchiveAllReadInbox,
useArchiveCompletedInbox,
useArchiveInbox,
useMarkAllInboxRead,
useMarkInboxRead,
} from "@/data/mutations/inbox";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { deduplicateInboxItems } from "@/lib/inbox-display";
export default function Inbox() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const qc = useQueryClient();
const { colorScheme } = useColorScheme();
const { data: rawItems, isLoading, error, refetch, isRefetching } = useQuery(
inboxListOptions(wsId),
);
@@ -40,6 +44,7 @@ export default function Inbox() {
[rawItems],
);
const markRead = useMarkInboxRead();
const markAllRead = useMarkAllInboxRead();
const archive = useArchiveInbox();
const archiveAll = useArchiveAllInbox();
const archiveAllRead = useArchiveAllReadInbox();
@@ -47,15 +52,10 @@ export default function Inbox() {
const onPressItem = (item: InboxItem) => {
if (!item.read) {
// Synchronous optimistic write so the row visibly transitions to the
// read style BEFORE router.push captures the source view screenshot
// for the native stack transition. The mutation's own onMutate writes
// optimistically too, but it awaits cancelQueries first — that one
// microtask is enough for iOS to freeze the row in its unread state
// inside the transition snapshot.
qc.setQueryData<InboxItem[]>(["inbox", wsId], (old) =>
old?.map((i) => (i.id === item.id ? { ...i, read: true } : i)),
);
// Optimistic read flip lives in useMarkInboxRead.onMutate — fires
// setQueryData synchronously before the cancelQueries await, so the
// row is already styled "read" by the time iOS captures the source
// snapshot for the native stack push transition.
markRead.mutate(item.id);
}
if (item.issue_id && wsSlug) {
@@ -71,12 +71,14 @@ export default function Inbox() {
}
};
// Trailing batch menu — mirrors desktop's dropdown
// (packages/views/inbox/components/inbox-page.tsx:220-235). "Archive all"
// is destructive so it gets the iOS red treatment + Alert confirm.
// Trailing batch menu — mirrors web's dropdown
// (packages/views/inbox/components/inbox-page.tsx). "Mark all read" is
// first (most common batch op); "Archive all" is destructive so it gets
// the iOS red treatment + Alert confirm.
const onPressMenu = () => {
const options = [
"Cancel",
"Mark all read",
"Archive all read",
"Archive completed",
"Archive all",
@@ -85,13 +87,14 @@ export default function Inbox() {
{
options,
cancelButtonIndex: 0,
destructiveButtonIndex: 3,
destructiveButtonIndex: 4,
title: "Inbox",
},
(i) => {
if (i === 1) archiveAllRead.mutate();
else if (i === 2) archiveCompleted.mutate();
else if (i === 3) {
if (i === 1) markAllRead.mutate();
else if (i === 2) archiveAllRead.mutate();
else if (i === 3) archiveCompleted.mutate();
else if (i === 4) {
Alert.alert(
"Archive all?",
"This archives every inbox item, read or unread. You can still find them via the issue pages.",
@@ -125,9 +128,7 @@ export default function Inbox() {
}
/>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
<InboxLoading />
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
@@ -139,17 +140,13 @@ export default function Inbox() {
</Button>
</View>
) : !data || data.length === 0 ? (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-sm text-muted-foreground">
No inbox items.
</Text>
</View>
<InboxEmpty iconColor={THEME[colorScheme].mutedForeground} />
) : (
<FlatList
data={data}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-[60px]" />
<View className="h-px bg-border ml-16" />
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => (
@@ -166,3 +163,37 @@ export default function Inbox() {
</View>
);
}
// Loading state — 6 row-shaped Skeletons matching InboxRow's layout
// (avatar circle + two text lines). Perceived perf wins over a centered
// spinner because the eye immediately sees the list-like structure.
function InboxLoading() {
return (
<View className="px-4 pt-4 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<View key={i} className="flex-row gap-3">
<Skeleton className="size-9 rounded-full" />
<View className="flex-1 gap-2 pt-1">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</View>
</View>
))}
</View>
);
}
function InboxEmpty({ iconColor }: { iconColor: string }) {
return (
<View className="flex-1 items-center justify-center px-8 gap-3">
<Ionicons name="mail-open-outline" size={42} color={iconColor} />
<Text className="text-base font-medium text-foreground text-center">
Inbox zero
</Text>
<Text className="text-sm text-muted-foreground text-center">
When someone @mentions you, assigns an issue, or an agent finishes a
task, it shows up here.
</Text>
</View>
);
}

View File

@@ -1,39 +1,44 @@
/**
* Left-swipe-to-archive wrapper for inbox rows.
* Left-swipe-to-reveal-Archive wrapper for inbox rows.
*
* iOS pattern reference: Mail.app / Linear iOS / Things — a destructive
* red Archive action revealed by a leftward drag, with auto-trigger on
* full swipe past threshold so the user doesn't have to release-then-tap.
* red Archive action revealed by a leftward drag. **Reveal-only, no
* auto-fire**: the previous version archived on full swipe past threshold,
* which felt aggressive (no peek, easy to trigger by accident on a fast
* vertical scroll). Mail.app / Linear require an explicit tap on the
* revealed action; we now match that. A medium haptic fires once when the
* row crosses the action width during the drag so the gesture still feels
* confirmed.
*
* Why ReanimatedSwipeable (not the legacy Swipeable): RNGH 2.20+ ships the
* Reanimated-driven implementation that integrates cleanly with the
* existing reanimated@4 install and runs the swipe on the UI thread (the
* legacy version uses Animated, which jankcs on heavy lists). The
* legacy version uses Animated, which janks on heavy lists). The
* gesture-handler root is already mounted in apps/mobile/app/_layout.tsx.
*
* Behaviour notes:
* - `friction=2` slightly slows the drag so the action doesn't fire by
* - `friction=2` slightly slows the drag so the action doesn't open by
* accident on a fast vertical scroll that catches some horizontal motion.
* - `rightThreshold=80` matches the visual width of the Archive button:
* past that, releasing auto-fires `onArchive`.
* - `onSwipeableOpen("right")` is the auto-trigger path. RNGH names the
* direction by where the action ENDS UP visible — `right` means
* `renderRightActions` is now exposed (the row slid left). Counter-
* intuitive but documented.
* - We `swipeable.close()` BEFORE calling onArchive so the row's exit
* - `rightThreshold=80` is the open-detent — releasing past it keeps the
* Archive button revealed; releasing short of it snaps closed. No
* auto-archive on cross.
* - We `swipeable.close()` before invoking onArchive so the row's exit
* from the FlatList (driven by the optimistic mutation flipping
* `archived: true`, which the parent's `deduplicateInboxItems` filters
* out) doesn't race the open animation. The row simply disappears from
* the list on next render — no fancy collapse needed for v1.
* - Tap on the revealed button is also supported: same flow.
* out) doesn't race the spring close.
*/
import { useRef } from "react";
import { Pressable, View } from "react-native";
import Animated, { type SharedValue } from "react-native-reanimated";
import Animated, {
type SharedValue,
useAnimatedReaction,
runOnJS,
} from "react-native-reanimated";
import ReanimatedSwipeable, {
type SwipeableMethods,
} from "react-native-gesture-handler/ReanimatedSwipeable";
import { Ionicons } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import type { InboxItem } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { InboxRow } from "./inbox-row";
@@ -61,28 +66,35 @@ export function SwipeableInboxRow({ item, onPress, onArchive }: Props) {
ref={ref}
friction={2}
rightThreshold={ACTION_WIDTH}
renderRightActions={(_progress, _drag) => (
<ArchiveAction onPress={fireArchive} drag={_drag} />
renderRightActions={(_progress, drag) => (
<ArchiveAction onPress={fireArchive} drag={drag} />
)}
onSwipeableOpen={(direction) => {
if (direction === "right") fireArchive();
}}
>
<InboxRow item={item} onPress={onPress} />
</ReanimatedSwipeable>
);
}
// `drag` is a SharedValue that goes from 0 → -ACTION_WIDTH as the row slides
// left. We could use it to drive a parallax effect on the icon; v1 just
// pins the icon to the right edge — sufficient for the intent.
function ArchiveAction({
onPress,
drag: _drag,
drag,
}: {
onPress: () => void;
drag: SharedValue<number>;
}) {
// One-shot haptic when the drag crosses the action width threshold.
// useAnimatedReaction runs on the UI thread; runOnJS bridges to the
// Haptics.impactAsync call which has to live on JS.
useAnimatedReaction(
() => drag.value <= -ACTION_WIDTH,
(crossed, prev) => {
if (crossed && !prev) {
runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Medium);
}
},
[],
);
return (
<Animated.View style={{ width: ACTION_WIDTH }}>
<Pressable

View File

@@ -0,0 +1,11 @@
import { cn } from '@/lib/utils';
import { View } from 'react-native';
function Skeleton({
className,
...props
}: React.ComponentProps<typeof View> & React.RefAttributes<View>) {
return <View className={cn('bg-accent animate-pulse rounded-md', className)} {...props} />;
}
export { Skeleton };

View File

@@ -443,6 +443,12 @@ class ApiClient {
return this.fetch<InboxItem>(`/api/inbox/${id}/archive`, { method: "POST" });
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch<{ count: number }>("/api/inbox/mark-all-read", {
method: "POST",
});
}
async archiveAllInbox(): Promise<{ count: number }> {
return this.fetch<{ count: number }>("/api/inbox/archive-all", {
method: "POST",

View File

@@ -5,11 +5,21 @@
*
* Behavioral parity:
* - mark-read: flip `read` to true locally; rollback on error; settle invalidate.
* `onMutate` writes setQueryData BEFORE awaiting cancelQueries — this is
* load-bearing for iOS Stack push transitions: when the user taps an
* inbox row and we router.push to issue/[id], iOS captures a snapshot of
* the source view for the slide animation; if the read-state flip hadn't
* landed in cache by that snapshot, the row appears unread frozen in
* the animation. Synchronous setQueryData ensures the next paint already
* has the flipped state. (Previously the caller did this hack at tap
* site; moved into the mutation so every caller benefits.)
* - archive single: flip `archived` to true on the item AND on every other
* inbox row that shares the same `issue_id` (web does the same — see
* packages/core/inbox/mutations.ts:37-46). Visually the row disappears
* because `deduplicateInboxItems` (apps/mobile/lib/inbox-display.ts)
* filters archived items out before render.
* - mark-all-read: flip `read` to true on every non-archived row (matches
* web; the server-side query does the same predicate).
* - archive batch (all / all-read / completed): no optimistic patch — the
* row predicates depend on server-side state (e.g. issue.status="done"
* isn't carried on every row, and mobile shouldn't re-derive the filter).
@@ -18,6 +28,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { InboxItem } from "@multica/core/types";
import { api } from "@/data/api";
import { inboxKeys } from "@/data/queries/inbox";
import { useWorkspaceStore } from "@/data/workspace-store";
export function useMarkInboxRead() {
@@ -27,19 +38,21 @@ export function useMarkInboxRead() {
return useMutation({
mutationFn: (id: string) => api.markInboxRead(id),
onMutate: async (id) => {
const key = ["inbox", wsId] as const;
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<InboxItem[]>(key);
const key = inboxKeys.list(wsId);
// Synchronous patch FIRST — see the file-level doc comment for why.
qc.setQueryData<InboxItem[]>(key, (old) =>
old?.map((item) => (item.id === id ? { ...item, read: true } : item)),
);
// Then the standard cancel + snapshot dance for rollback.
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<InboxItem[]>(key);
return { prev, key };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ["inbox", wsId] });
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
@@ -51,7 +64,7 @@ export function useArchiveInbox() {
return useMutation({
mutationFn: (id: string) => api.archiveInbox(id),
onMutate: async (id) => {
const key = ["inbox", wsId] as const;
const key = inboxKeys.list(wsId);
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<InboxItem[]>(key);
// Match web: archive every row that shares the same issue_id — the
@@ -74,7 +87,33 @@ export function useArchiveInbox() {
if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ["inbox", wsId] });
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useMarkAllInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
return useMutation({
mutationFn: () => api.markAllInboxRead(),
onMutate: async () => {
const key = inboxKeys.list(wsId);
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<InboxItem[]>(key);
qc.setQueryData<InboxItem[]>(key, (old) =>
old?.map((item) =>
!item.archived ? { ...item, read: true } : item,
),
);
return { prev, key };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
@@ -89,7 +128,7 @@ export function useArchiveAllInbox() {
return useMutation({
mutationFn: () => api.archiveAllInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: ["inbox", wsId] });
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
@@ -100,7 +139,7 @@ export function useArchiveAllReadInbox() {
return useMutation({
mutationFn: () => api.archiveAllReadInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: ["inbox", wsId] });
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
@@ -111,7 +150,7 @@ export function useArchiveCompletedInbox() {
return useMutation({
mutationFn: () => api.archiveCompletedInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: ["inbox", wsId] });
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}

View File

@@ -26,6 +26,7 @@ import type {
} from "@multica/core/types";
import { api } from "@/data/api";
import { issueKeys } from "@/data/queries/issues";
import { inboxKeys } from "@/data/queries/inbox";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
@@ -366,7 +367,8 @@ export function useDetachLabel(issueId: string) {
*
* Invalidates:
* - issueKeys.myAll(wsId) my-issues list (all three scopes)
* - ["inbox", wsId] inbox (assignment notification if any)
* - inboxKeys.all(wsId) inbox (assignment notification if any)
* prefix-matches the inbox list key
*/
export function useCreateIssue() {
const qc = useQueryClient();
@@ -376,7 +378,7 @@ export function useCreateIssue() {
mutationFn: (body: CreateIssueRequest) => api.createIssue(body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: ["inbox", wsId] });
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
},
});
}

View File

@@ -2,13 +2,21 @@ import { queryOptions } from "@tanstack/react-query";
import { api } from "@/data/api";
/**
* Inbox query — keyed on wsId so switching workspaces auto-invalidates the
* cache (TQ sees a new key and refetches). Same pattern as web/desktop's
* inboxKeys.list(wsId) in packages/core/inbox/queries.ts.
* Inbox cache key factory.
*
* Shape mirrors web's `packages/core/inbox/queries.ts` — `["inbox", wsId, "list"]`
* — so cross-platform mental model stays the same. Keying on wsId means
* workspace switches naturally invalidate (TQ sees a new key and refetches).
*/
export const inboxKeys = {
all: (wsId: string | null) => ["inbox", wsId] as const,
list: (wsId: string | null) =>
[...inboxKeys.all(wsId), "list"] as const,
};
export const inboxListOptions = (wsId: string | null) =>
queryOptions({
queryKey: ["inbox", wsId] as const,
queryKey: inboxKeys.list(wsId),
queryFn: ({ signal }) => api.listInbox({ signal }),
enabled: !!wsId,
});

View File

@@ -0,0 +1,42 @@
/**
* Mobile inbox cache patchers. Mirrors `packages/core/inbox/ws-updaters.ts`
* (per CLAUDE.md "Mobile-owned updaters" — copy the design, don't import:
* key factory binding + cache shape can drift independently).
*
* Two cross-cutting events that change inbox state without firing an
* `inbox:*` event:
* - `issue:updated` carrying a new status → the inbox row's StatusIcon
* must update inline. Without this patch the row keeps showing the
* prior status until the next inbox event triggers a full refetch.
* - `issue:deleted` → all inbox items pointing at that issue are gone
* server-side (FK ON DELETE CASCADE in the DB); the cache should drop
* them too, otherwise tapping an inbox row navigates to a 404 issue.
*
* Listing-level only; use-inbox-realtime wires these into the WS layer.
*/
import type { QueryClient } from "@tanstack/react-query";
import type { InboxItem, IssueStatus } from "@multica/core/types";
import { inboxKeys } from "@/data/queries/inbox";
export function patchInboxIssueStatus(
qc: QueryClient,
wsId: string,
issueId: string,
status: IssueStatus,
) {
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((i) =>
i.issue_id === issueId ? { ...i, issue_status: status } : i,
),
);
}
export function dropInboxItemsByIssue(
qc: QueryClient,
wsId: string,
issueId: string,
) {
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.filter((i) => i.issue_id !== issueId),
);
}

View File

@@ -1,42 +1,69 @@
/**
* Inbox realtime — Layer 3 of the realtime stack.
*
* Listens for inbox-domain events on the shared WSClient and invalidates
* the inbox query so TanStack Query refetches. Also re-invalidates on
* reconnect because we may have missed events while disconnected (no
* server-side replay in v1; that's a future optimization on top of the
* Redis relay buffer the server already maintains).
* Two subscription groups:
*
* Web's equivalent does fancier per-event work (specific handlers that
* mutate the cache without a refetch, OS notifications on inbox:new,
* etc — see packages/core/realtime/use-realtime-sync.ts). Mobile v1
* starts with the simplest correct path: invalidate-and-refetch. Push
* notifications for backgrounded delivery come later via APNs, not WS.
* 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 queryClient = useQueryClient();
const qc = useQueryClient();
useWSSubscriptions(
(ws, wsId) => {
// Same key shape as data/queries/inbox.ts → inboxListOptions(wsId).
// Keying on wsId means workspace switches naturally invalidate.
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["inbox", 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),
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),
];
},
[queryClient],
[qc],
);
}