mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
11
apps/mobile/components/ui/skeleton.tsx
Normal file
11
apps/mobile/components/ui/skeleton.tsx
Normal 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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
42
apps/mobile/data/realtime/inbox-ws-updaters.ts
Normal file
42
apps/mobile/data/realtime/inbox-ws-updaters.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user