diff options
| -rw-r--r-- | apps/web/app/(marketing)/_components/feature-grid.tsx | 5 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/offline-banner.tsx | 10 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/reader-layout-shell.tsx | 2 | ||||
| -rw-r--r-- | apps/web/app/sw.ts | 16 | ||||
| -rw-r--r-- | apps/web/lib/hooks/use-offline-access-sync.ts | 24 | ||||
| -rw-r--r-- | packages/shared/source/index.ts | 3 |
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 |