diff --git a/apps/native/src/hooks/use-widget-refresh.ts b/apps/native/src/hooks/use-widget-refresh.ts index d6b4276..7b6c4c7 100644 --- a/apps/native/src/hooks/use-widget-refresh.ts +++ b/apps/native/src/hooks/use-widget-refresh.ts @@ -1,25 +1,35 @@ import { useEffect, useRef } from "react"; import { AppState, Platform } from "react-native"; -import { refreshWidgets } from "@/lib/widgets"; +import { refreshWidgets, resetWidgets } from "@/lib/widgets"; const THROTTLE_MS = 60_000; // Don't refresh more than once per minute on foreground export function useWidgetRefresh(isReady: boolean) { const lastRefresh = useRef(0); + const hadSession = useRef(false); - // Refresh when auth/server becomes ready (handles cold launch and login) - useEffect(() => { - if (Platform.OS !== "ios" || !isReady) return; - - void refreshWidgets(); - lastRefresh.current = Date.now(); - }, [isReady]); - - // Refresh on app foreground + // Refresh when auth/server becomes ready (handles cold launch and login); + // reset widgets when the session goes away (sign-out, server switch). useEffect(() => { if (Platform.OS !== "ios") return; + if (isReady) { + hadSession.current = true; + void refreshWidgets(); + lastRefresh.current = Date.now(); + } else if (hadSession.current) { + // Only reset after a session was seen: on cold launch the session may + // still be loading, and resetting then would blank widgets every launch. + hadSession.current = false; + void resetWidgets(); + } + }, [isReady]); + + // Refresh on app foreground (only while signed in) + useEffect(() => { + if (Platform.OS !== "ios" || !isReady) return; + const subscription = AppState.addEventListener("change", (state) => { if (state === "active") { const now = Date.now(); @@ -31,5 +41,5 @@ export function useWidgetRefresh(isReady: boolean) { }); return () => subscription.remove(); - }, []); + }, [isReady]); } diff --git a/apps/native/src/lib/widgets.test.ts b/apps/native/src/lib/widgets.test.ts index f51f280..8ff8b81 100644 --- a/apps/native/src/lib/widgets.test.ts +++ b/apps/native/src/lib/widgets.test.ts @@ -18,6 +18,7 @@ const copyBundledAsset = vi.fn<(assetUri: string, key: string) => Promise `file:///group/${key}`, ); const pruneWidgetImages = vi.fn<() => Promise>(async () => undefined); +const clearWidgetImages = vi.fn<() => Promise>(async () => undefined); const getWidgetIconAsset = vi.fn<() => string>(() => "widget-icon-asset"); const continueWatchingWidget = { @@ -55,6 +56,7 @@ vi.mock("@/lib/server", () => ({ })); vi.mock("../../modules/sofa-widgets-support", () => ({ + clearWidgetImages, copyBundledAsset, downloadWidgetImage, pruneWidgetImages, @@ -88,6 +90,7 @@ beforeEach(() => { async (_assetUri: string, key: string) => `file:///group/${key}`, ); pruneWidgetImages.mockResolvedValue(undefined); + clearWidgetImages.mockResolvedValue(undefined); resolveAssetSource.mockReturnValue({ uri: "file:///tmp/sofa-icon.png" }); getWidgetIconAsset.mockReturnValue("widget-icon-asset"); }); @@ -220,3 +223,40 @@ describe("refreshWidgets", () => { expect(pruneWidgetImages).not.toHaveBeenCalled(); }); }); + +describe("resetWidgets", () => { + test("clears cached images and writes empty snapshots", async () => { + const { resetWidgets } = await loadWidgetsModule(); + await resetWidgets(); + + expect(clearWidgetImages).toHaveBeenCalledTimes(1); + expect(continueWatchingWidget.updateSnapshot).toHaveBeenCalledTimes(1); + expect(upcomingWidget.updateSnapshot).toHaveBeenCalledTimes(1); + expect(continueWatchingWidget.updateSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ titleId: "", iconFilePath: "file:///group/sofa_icon.png" }), + ); + expect(upcomingWidget.updateSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ titleId: "", iconFilePath: "file:///group/sofa_icon.png" }), + ); + expect(continueWatching).not.toHaveBeenCalled(); + expect(upcoming).not.toHaveBeenCalled(); + // The icon lives in the cleared directory, so it must be copied again afterwards. + expect(clearWidgetImages.mock.invocationCallOrder[0]).toBeLessThan( + copyBundledAsset.mock.invocationCallOrder[0]!, + ); + }); + + test("is a no-op outside iOS", async () => { + platform.OS = "android"; + + const { resetWidgets } = await loadWidgetsModule(); + await resetWidgets(); + + expect(clearWidgetImages).not.toHaveBeenCalled(); + expect(copyBundledAsset).not.toHaveBeenCalled(); + expect(continueWatchingWidget.updateSnapshot).not.toHaveBeenCalled(); + expect(upcomingWidget.updateSnapshot).not.toHaveBeenCalled(); + expect(continueWatching).not.toHaveBeenCalled(); + expect(upcoming).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/native/src/lib/widgets.ts b/apps/native/src/lib/widgets.ts index 6e5cc6d..61122fc 100644 --- a/apps/native/src/lib/widgets.ts +++ b/apps/native/src/lib/widgets.ts @@ -7,6 +7,7 @@ import type { ContinueWatchingProps } from "@/widgets/continue-watching"; import type { UpcomingProps } from "@/widgets/upcoming"; import { + clearWidgetImages, copyBundledAsset, downloadWidgetImage, pruneWidgetImages, @@ -113,6 +114,52 @@ async function ensureIcon(): Promise { } } +function emptyContinueWatchingProps(iconFilePath: string): ContinueWatchingProps { + return sanitizeProps({ + iconFilePath, + titleId: "", + titleName: "", + imageFilePath: "", + watchedEpisodes: 0, + totalEpisodes: 0, + isMovie: false, + }); +} + +function emptyUpcomingProps(iconFilePath: string): UpcomingProps { + return sanitizeProps({ + iconFilePath, + titleId: "", + titleName: "", + imageFilePath: "", + titleType: "tv", + episodeCount: 0, + dateLabel: "", + episodeLabel: "", + }); +} + +/** Replace widget content with empty states and delete cached artwork (sign-out / server change). */ +export async function resetWidgets(): Promise { + if (Platform.OS !== "ios") return; + + const [{ default: ContinueWatchingWidget }, { default: UpcomingWidget }] = await Promise.all([ + import("@/widgets/continue-watching"), + import("@/widgets/upcoming"), + ]); + + try { + await clearWidgetImages(); + } catch (error) { + console.warn("[Widgets] Failed to clear cached images:", error); + } + + // clearWidgetImages() also removes the cached icon, so copy it again. + const iconFilePath = await ensureIcon(); + ContinueWatchingWidget.updateSnapshot(emptyContinueWatchingProps(iconFilePath)); + UpcomingWidget.updateSnapshot(emptyUpcomingProps(iconFilePath)); +} + export async function refreshWidgets(): Promise { if (Platform.OS !== "ios") return; @@ -143,17 +190,7 @@ async function refreshContinueWatching( const { items } = await client.library.continueWatching(); if (items.length === 0) { - widget.updateSnapshot( - sanitizeProps({ - iconFilePath, - titleId: "", - titleName: "", - imageFilePath: "", - watchedEpisodes: 0, - totalEpisodes: 0, - isMovie: false, - }), - ); + widget.updateSnapshot(emptyContinueWatchingProps(iconFilePath)); return; } @@ -206,18 +243,7 @@ async function refreshUpcoming( }); if (items.length === 0) { - widget.updateSnapshot( - sanitizeProps({ - iconFilePath, - titleId: "", - titleName: "", - imageFilePath: "", - titleType: "tv", - episodeCount: 0, - dateLabel: "", - episodeLabel: "", - }), - ); + widget.updateSnapshot(emptyUpcomingProps(iconFilePath)); return; }