summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-10 01:32:16 -0800
committerFuwn <[email protected]>2026-02-10 01:32:16 -0800
commit293f9ccc308b69f507e752af01a49652faee330f (patch)
treeb699e52309cfab03d539a048de34a98158d6168b
parentfeat: add automatic timeline refresh with scroll position preservation (diff)
downloadasa.news-293f9ccc308b69f507e752af01a49652faee330f.tar.xz
asa.news-293f9ccc308b69f507e752af01a49652faee330f.zip
feat: gate offline reading to pro and developer plans
Service worker now only caches Supabase REST responses when the user's tier allows offline reading. Client syncs tier status to SW via postMessage after profile loads. Free users see a descriptive offline banner instead of stale cached data.
-rw-r--r--apps/web/app/(marketing)/_components/feature-grid.tsx5
-rw-r--r--apps/web/app/reader/_components/offline-banner.tsx10
-rw-r--r--apps/web/app/reader/_components/reader-layout-shell.tsx2
-rw-r--r--apps/web/app/sw.ts16
-rw-r--r--apps/web/lib/hooks/use-offline-access-sync.ts24
-rw-r--r--packages/shared/source/index.ts3
6 files changed, 59 insertions, 1 deletions
diff --git a/apps/web/app/(marketing)/_components/feature-grid.tsx b/apps/web/app/(marketing)/_components/feature-grid.tsx
index 640b5d0..9f3fdcc 100644
--- a/apps/web/app/(marketing)/_components/feature-grid.tsx
+++ b/apps/web/app/(marketing)/_components/feature-grid.tsx
@@ -29,6 +29,11 @@ const FEATURES = [
description:
"new entries appear automatically or via notification \u2014 your choice. scroll position is always preserved.",
},
+ {
+ title: "offline reading",
+ description:
+ "read cached articles without an internet connection. available on pro and developer plans.",
+ },
]
export function FeatureGrid() {
diff --git a/apps/web/app/reader/_components/offline-banner.tsx b/apps/web/app/reader/_components/offline-banner.tsx
index 81ebf8f..b6089de 100644
--- a/apps/web/app/reader/_components/offline-banner.tsx
+++ b/apps/web/app/reader/_components/offline-banner.tsx
@@ -1,6 +1,8 @@
"use client"
import { useSyncExternalStore } from "react"
+import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared"
+import { useUserProfile } from "@/lib/queries/use-user-profile"
function subscribe(callback: () => void) {
window.addEventListener("online", callback)
@@ -21,12 +23,18 @@ function getServerSnapshot() {
export function OfflineBanner() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
+ const { data: userProfile } = useUserProfile()
if (isOnline) return null
+ const tier = (userProfile?.tier ?? "free") as SubscriptionTier
+ const allowsOffline = TIER_LIMITS[tier]?.allowsOfflineReading ?? false
+
return (
<div className="border-b border-border bg-background-tertiary px-3 py-1.5 text-center text-text-dim">
- you are offline — showing cached content
+ {allowsOffline
+ ? "you are offline \u2014 showing cached content"
+ : "you are offline \u2014 offline reading is available on pro and developer plans"}
</div>
)
}
diff --git a/apps/web/app/reader/_components/reader-layout-shell.tsx b/apps/web/app/reader/_components/reader-layout-shell.tsx
index 7ec1002..17ae0b5 100644
--- a/apps/web/app/reader/_components/reader-layout-shell.tsx
+++ b/apps/web/app/reader/_components/reader-layout-shell.tsx
@@ -14,6 +14,7 @@ import { KeyboardShortcutsDialog } from "./keyboard-shortcuts-dialog"
import { MfaChallenge } from "./mfa-challenge"
import { OfflineBanner } from "./offline-banner"
import { useKeyboardNavigation } from "@/lib/hooks/use-keyboard-navigation"
+import { useOfflineAccessSync } from "@/lib/hooks/use-offline-access-sync"
import { createSupabaseBrowserClient } from "@/lib/supabase/client"
import { useSubscriptions } from "@/lib/queries/use-subscriptions"
import { useCustomFeeds } from "@/lib/queries/use-custom-feeds"
@@ -58,6 +59,7 @@ export function ReaderLayoutShell({
const { data: subscriptionsData } = useSubscriptions()
const { data: customFeedsData } = useCustomFeeds()
const { data: userProfile } = useUserProfile()
+ useOfflineAccessSync(userProfile?.tier as "free" | "pro" | "developer" | undefined)
const sidebarMaxWidth = useMemo(() => {
if (typeof window === "undefined") return "35%"
diff --git a/apps/web/app/sw.ts b/apps/web/app/sw.ts
index c6dd082..955856b 100644
--- a/apps/web/app/sw.ts
+++ b/apps/web/app/sw.ts
@@ -11,6 +11,21 @@ declare global {
declare const self: ServiceWorkerGlobalScope
+let offlineReadingAllowed = false
+
+self.addEventListener("message", (event) => {
+ if (event.data?.type === "SET_OFFLINE_ACCESS") {
+ offlineReadingAllowed = event.data.allowed === true
+ }
+})
+
+const offlineGatePlugin = {
+ cacheWillUpdate: async ({ response }: { response: Response }): Promise<Response | null> => {
+ if (!offlineReadingAllowed) return null
+ return response
+ },
+}
+
const sameOriginCache = defaultCache.map((entry) => ({
...entry,
matcher: (parameters: Parameters<Exclude<(typeof defaultCache)[number]["matcher"], RegExp | string | undefined>>[0]) => {
@@ -37,6 +52,7 @@ const serwist = new Serwist({
cacheName: "supabase-rest-get",
networkTimeoutSeconds: 10,
plugins: [
+ offlineGatePlugin,
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24,
diff --git a/apps/web/lib/hooks/use-offline-access-sync.ts b/apps/web/lib/hooks/use-offline-access-sync.ts
new file mode 100644
index 0000000..a324d86
--- /dev/null
+++ b/apps/web/lib/hooks/use-offline-access-sync.ts
@@ -0,0 +1,24 @@
+"use client"
+
+import { useEffect } from "react"
+import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared"
+
+export function useOfflineAccessSync(tier: SubscriptionTier | undefined) {
+ useEffect(() => {
+ if (!tier) return
+
+ const allowed = TIER_LIMITS[tier]?.allowsOfflineReading ?? false
+
+ navigator.serviceWorker?.controller?.postMessage({
+ type: "SET_OFFLINE_ACCESS",
+ allowed,
+ })
+
+ navigator.serviceWorker?.ready.then((registration) => {
+ registration.active?.postMessage({
+ type: "SET_OFFLINE_ACCESS",
+ allowed,
+ })
+ })
+ }, [tier])
+}
diff --git a/packages/shared/source/index.ts b/packages/shared/source/index.ts
index cf3a124..335a74e 100644
--- a/packages/shared/source/index.ts
+++ b/packages/shared/source/index.ts
@@ -13,6 +13,7 @@ export const TIER_LIMITS = {
allowsManualRefresh: false,
allowsApiAccess: false,
allowsWebhooks: false,
+ allowsOfflineReading: false,
},
pro: {
maximumFeeds: 200,
@@ -26,6 +27,7 @@ export const TIER_LIMITS = {
allowsManualRefresh: true,
allowsApiAccess: false,
allowsWebhooks: false,
+ allowsOfflineReading: true,
},
developer: {
maximumFeeds: 500,
@@ -39,6 +41,7 @@ export const TIER_LIMITS = {
allowsManualRefresh: true,
allowsApiAccess: true,
allowsWebhooks: true,
+ allowsOfflineReading: true,
},
} as const