diff --git a/apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx b/apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx index 9d431c218..6806bf44b 100644 --- a/apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx +++ b/apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx @@ -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(["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 ? ( - - - + ) : error ? ( @@ -139,17 +140,13 @@ export default function Inbox() { ) : !data || data.length === 0 ? ( - - - No inbox items. - - + ) : ( item.id} ItemSeparatorComponent={() => ( - + )} contentContainerClassName="pb-6" renderItem={({ item }) => ( @@ -166,3 +163,37 @@ export default function Inbox() { ); } + +// 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 ( + + {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + ))} + + ); +} + +function InboxEmpty({ iconColor }: { iconColor: string }) { + return ( + + + + Inbox zero + + + When someone @mentions you, assigns an issue, or an agent finishes a + task, it shows up here. + + + ); +} diff --git a/apps/mobile/components/inbox/swipeable-inbox-row.tsx b/apps/mobile/components/inbox/swipeable-inbox-row.tsx index 10fd201f7..8d680f147 100644 --- a/apps/mobile/components/inbox/swipeable-inbox-row.tsx +++ b/apps/mobile/components/inbox/swipeable-inbox-row.tsx @@ -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) => ( - + renderRightActions={(_progress, drag) => ( + )} - onSwipeableOpen={(direction) => { - if (direction === "right") fireArchive(); - }} > ); } -// `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; }) { + // 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 ( & React.RefAttributes) { + return ; +} + +export { Skeleton }; diff --git a/apps/mobile/data/api.ts b/apps/mobile/data/api.ts index c0529e7d5..ce0e9d857 100644 --- a/apps/mobile/data/api.ts +++ b/apps/mobile/data/api.ts @@ -443,6 +443,12 @@ class ApiClient { return this.fetch(`/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", diff --git a/apps/mobile/data/mutations/inbox.ts b/apps/mobile/data/mutations/inbox.ts index 10e876724..ad1765df1 100644 --- a/apps/mobile/data/mutations/inbox.ts +++ b/apps/mobile/data/mutations/inbox.ts @@ -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(key); + const key = inboxKeys.list(wsId); + // Synchronous patch FIRST — see the file-level doc comment for why. qc.setQueryData(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(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(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(key); + qc.setQueryData(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) }); }, }); } diff --git a/apps/mobile/data/mutations/issues.ts b/apps/mobile/data/mutations/issues.ts index e0d64dd85..28e0f3fe6 100644 --- a/apps/mobile/data/mutations/issues.ts +++ b/apps/mobile/data/mutations/issues.ts @@ -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) }); }, }); } diff --git a/apps/mobile/data/queries/inbox.ts b/apps/mobile/data/queries/inbox.ts index bd7515c8e..a5b351f08 100644 --- a/apps/mobile/data/queries/inbox.ts +++ b/apps/mobile/data/queries/inbox.ts @@ -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, }); diff --git a/apps/mobile/data/realtime/inbox-ws-updaters.ts b/apps/mobile/data/realtime/inbox-ws-updaters.ts new file mode 100644 index 000000000..5220872e7 --- /dev/null +++ b/apps/mobile/data/realtime/inbox-ws-updaters.ts @@ -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(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(inboxKeys.list(wsId), (old) => + old?.filter((i) => i.issue_id !== issueId), + ); +} diff --git a/apps/mobile/data/realtime/use-inbox-realtime.ts b/apps/mobile/data/realtime/use-inbox-realtime.ts index 976a02618..6fc885053 100644 --- a/apps/mobile/data/realtime/use-inbox-realtime.ts +++ b/apps/mobile/data/realtime/use-inbox-realtime.ts @@ -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], ); }