summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-09 23:24:17 -0800
committerFuwn <[email protected]>2026-02-09 23:24:17 -0800
commit77d239084b85b82bac9e7c7910230351f07078bb (patch)
tree340bb8c2109f5c4261b15b6146f8ce75ab7d4f8f /apps
parentfeat: offline support tier 2 — prefetch entry content and SW runtime caching (diff)
downloadasa.news-77d239084b85b82bac9e7c7910230351f07078bb.tar.xz
asa.news-77d239084b85b82bac9e7c7910230351f07078bb.zip
feat: offline support tier 3 — mutation queue and image caching
Paused mutations (read/save toggles) are now persisted to IndexedDB and automatically resumed on reconnection or page reload via TanStack Query's offlineFirst networkMode. Service worker caches images with CacheFirst strategy (500 entries, 7-day expiry) for offline reading.
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/providers.tsx80
-rw-r--r--apps/web/app/sw.ts14
-rw-r--r--apps/web/lib/queries/use-entry-state-mutations.ts3
-rw-r--r--apps/web/lib/query-client.ts9
4 files changed, 103 insertions, 3 deletions
diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx
index 8551748..550b30c 100644
--- a/apps/web/app/providers.tsx
+++ b/apps/web/app/providers.tsx
@@ -1,10 +1,11 @@
"use client"
-import { useState } from "react"
+import { useState, useEffect } from "react"
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"
import { Toaster } from "sonner"
-import { createQueryClient } from "@/lib/query-client"
+import { createQueryClient, MUTATION_KEYS } from "@/lib/query-client"
import { createIndexedDatabasePersister } from "@/lib/indexed-database-persister"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
const PERSISTABLE_QUERY_KEY_PREFIXES = [
"timeline",
@@ -18,10 +19,82 @@ const PERSISTABLE_QUERY_KEY_PREFIXES = [
"custom-feed-timeline",
]
+function useResumableMutations(queryClient: ReturnType<typeof createQueryClient>) {
+ useEffect(() => {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ queryClient.setMutationDefaults(MUTATION_KEYS.toggleEntryReadState, {
+ mutationFn: async ({
+ entryIdentifier,
+ isRead,
+ }: {
+ entryIdentifier: string
+ isRead: boolean
+ }) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("not authenticated")
+
+ const { error } = await supabaseClient
+ .from("user_entry_states")
+ .upsert(
+ {
+ user_id: user.id,
+ entry_id: entryIdentifier,
+ read: isRead,
+ read_at: isRead ? new Date().toISOString() : null,
+ },
+ { onConflict: "user_id,entry_id" }
+ )
+
+ if (error) throw error
+ },
+ })
+
+ queryClient.setMutationDefaults(MUTATION_KEYS.toggleEntrySavedState, {
+ mutationFn: async ({
+ entryIdentifier,
+ isSaved,
+ }: {
+ entryIdentifier: string
+ isSaved: boolean
+ }) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("not authenticated")
+
+ const { error } = await supabaseClient
+ .from("user_entry_states")
+ .upsert(
+ {
+ user_id: user.id,
+ entry_id: entryIdentifier,
+ saved: isSaved,
+ saved_at: isSaved ? new Date().toISOString() : null,
+ },
+ { onConflict: "user_id,entry_id" }
+ )
+
+ if (error) throw error
+ },
+ })
+
+ queryClient.resumePausedMutations().then(() => {
+ queryClient.invalidateQueries()
+ })
+ }, [queryClient])
+}
+
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(createQueryClient)
const [persister] = useState(createIndexedDatabasePersister)
+ useResumableMutations(queryClient)
+
return (
<PersistQueryClientProvider
client={queryClient}
@@ -33,6 +106,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
const prefix = query.queryKey[0]
return typeof prefix === "string" && PERSISTABLE_QUERY_KEY_PREFIXES.includes(prefix)
},
+ shouldDehydrateMutation: (mutation) => {
+ return mutation.state.isPaused
+ },
},
}}
>
diff --git a/apps/web/app/sw.ts b/apps/web/app/sw.ts
index e18cc5a..f3bb4a8 100644
--- a/apps/web/app/sw.ts
+++ b/apps/web/app/sw.ts
@@ -1,7 +1,7 @@
/// <reference lib="webworker" />
import { defaultCache } from "@serwist/next/worker"
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist"
-import { Serwist, NetworkFirst, ExpirationPlugin } from "serwist"
+import { Serwist, NetworkFirst, CacheFirst, ExpirationPlugin } from "serwist"
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
@@ -44,6 +44,18 @@ const serwist = new Serwist({
],
}),
},
+ {
+ matcher: ({ request }) => request.destination === "image",
+ handler: new CacheFirst({
+ cacheName: "entry-images",
+ plugins: [
+ new ExpirationPlugin({
+ maxEntries: 500,
+ maxAgeSeconds: 60 * 60 * 24 * 7,
+ }),
+ ],
+ }),
+ },
...sameOriginCache,
],
})
diff --git a/apps/web/lib/queries/use-entry-state-mutations.ts b/apps/web/lib/queries/use-entry-state-mutations.ts
index 80bab79..b5f64e9 100644
--- a/apps/web/lib/queries/use-entry-state-mutations.ts
+++ b/apps/web/lib/queries/use-entry-state-mutations.ts
@@ -3,6 +3,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { createSupabaseBrowserClient } from "@/lib/supabase/client"
import { queryKeys } from "./query-keys"
+import { MUTATION_KEYS } from "@/lib/query-client"
import type { TimelineEntry } from "@/lib/types/timeline"
import type { InfiniteData } from "@tanstack/react-query"
@@ -11,6 +12,7 @@ export function useToggleEntryReadState() {
const queryClient = useQueryClient()
return useMutation({
+ mutationKey: MUTATION_KEYS.toggleEntryReadState,
mutationFn: async ({
entryIdentifier,
isRead,
@@ -84,6 +86,7 @@ export function useToggleEntrySavedState() {
const queryClient = useQueryClient()
return useMutation({
+ mutationKey: MUTATION_KEYS.toggleEntrySavedState,
mutationFn: async ({
entryIdentifier,
isSaved,
diff --git a/apps/web/lib/query-client.ts b/apps/web/lib/query-client.ts
index c64e4c7..1e500a0 100644
--- a/apps/web/lib/query-client.ts
+++ b/apps/web/lib/query-client.ts
@@ -2,6 +2,11 @@ import { QueryClient } from "@tanstack/react-query"
const TWENTY_FOUR_HOURS_IN_MILLISECONDS = 1000 * 60 * 60 * 24
+export const MUTATION_KEYS = {
+ toggleEntryReadState: ["toggle-entry-read-state"] as const,
+ toggleEntrySavedState: ["toggle-entry-saved-state"] as const,
+}
+
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
@@ -10,6 +15,10 @@ export function createQueryClient() {
gcTime: TWENTY_FOUR_HOURS_IN_MILLISECONDS,
refetchOnWindowFocus: false,
},
+ mutations: {
+ networkMode: "offlineFirst",
+ gcTime: TWENTY_FOUR_HOURS_IN_MILLISECONDS,
+ },
},
})
}