fix(native): reset home-screen widgets when the session ends

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 15:52:54 -04:00
1 parent 4ab152cbcc
commit 923924a76f
3 files changed
+110 -34

No files matched your search

+21 -11
View File
@@ -1,25 +1,35 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { AppState, Platform } from "react-native"; 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 const THROTTLE_MS = 60_000; // Don't refresh more than once per minute on foreground
export function useWidgetRefresh(isReady: boolean) { export function useWidgetRefresh(isReady: boolean) {
const lastRefresh = useRef(0); const lastRefresh = useRef(0);
const hadSession = useRef(false);
// Refresh when auth/server becomes ready (handles cold launch and login) // Refresh when auth/server becomes ready (handles cold launch and login);
useEffect(() => { // reset widgets when the session goes away (sign-out, server switch).
if (Platform.OS !== "ios" || !isReady) return;
void refreshWidgets();
lastRefresh.current = Date.now();
}, [isReady]);
// Refresh on app foreground
useEffect(() => { useEffect(() => {
if (Platform.OS !== "ios") return; 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) => { const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") { if (state === "active") {
const now = Date.now(); const now = Date.now();
@@ -31,5 +41,5 @@ export function useWidgetRefresh(isReady: boolean) {
}); });
return () => subscription.remove(); return () => subscription.remove();
}, []); }, [isReady]);
} }
+40
View File
@@ -18,6 +18,7 @@ const copyBundledAsset = vi.fn<(assetUri: string, key: string) => Promise<string
async (_assetUri: string, key: string) => `file:///group/${key}`, async (_assetUri: string, key: string) => `file:///group/${key}`,
); );
const pruneWidgetImages = vi.fn<() => Promise<void>>(async () => undefined); const pruneWidgetImages = vi.fn<() => Promise<void>>(async () => undefined);
const clearWidgetImages = vi.fn<() => Promise<void>>(async () => undefined);
const getWidgetIconAsset = vi.fn<() => string>(() => "widget-icon-asset"); const getWidgetIconAsset = vi.fn<() => string>(() => "widget-icon-asset");
const continueWatchingWidget = { const continueWatchingWidget = {
@@ -55,6 +56,7 @@ vi.mock("@/lib/server", () => ({
})); }));
vi.mock("../../modules/sofa-widgets-support", () => ({ vi.mock("../../modules/sofa-widgets-support", () => ({
clearWidgetImages,
copyBundledAsset, copyBundledAsset,
downloadWidgetImage, downloadWidgetImage,
pruneWidgetImages, pruneWidgetImages,
@@ -88,6 +90,7 @@ beforeEach(() => {
async (_assetUri: string, key: string) => `file:///group/${key}`, async (_assetUri: string, key: string) => `file:///group/${key}`,
); );
pruneWidgetImages.mockResolvedValue(undefined); pruneWidgetImages.mockResolvedValue(undefined);
clearWidgetImages.mockResolvedValue(undefined);
resolveAssetSource.mockReturnValue({ uri: "file:///tmp/sofa-icon.png" }); resolveAssetSource.mockReturnValue({ uri: "file:///tmp/sofa-icon.png" });
getWidgetIconAsset.mockReturnValue("widget-icon-asset"); getWidgetIconAsset.mockReturnValue("widget-icon-asset");
}); });
@@ -220,3 +223,40 @@ describe("refreshWidgets", () => {
expect(pruneWidgetImages).not.toHaveBeenCalled(); 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();
});
});
+49 -23
View File
@@ -7,6 +7,7 @@ import type { ContinueWatchingProps } from "@/widgets/continue-watching";
import type { UpcomingProps } from "@/widgets/upcoming"; import type { UpcomingProps } from "@/widgets/upcoming";
import { import {
clearWidgetImages,
copyBundledAsset, copyBundledAsset,
downloadWidgetImage, downloadWidgetImage,
pruneWidgetImages, pruneWidgetImages,
@@ -113,6 +114,52 @@ async function ensureIcon(): Promise<string> {
} }
} }
function emptyContinueWatchingProps(iconFilePath: string): ContinueWatchingProps {
return sanitizeProps<ContinueWatchingProps>({
iconFilePath,
titleId: "",
titleName: "",
imageFilePath: "",
watchedEpisodes: 0,
totalEpisodes: 0,
isMovie: false,
});
}
function emptyUpcomingProps(iconFilePath: string): UpcomingProps {
return sanitizeProps<UpcomingProps>({
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<void> {
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<void> { export async function refreshWidgets(): Promise<void> {
if (Platform.OS !== "ios") return; if (Platform.OS !== "ios") return;
@@ -143,17 +190,7 @@ async function refreshContinueWatching(
const { items } = await client.library.continueWatching(); const { items } = await client.library.continueWatching();
if (items.length === 0) { if (items.length === 0) {
widget.updateSnapshot( widget.updateSnapshot(emptyContinueWatchingProps(iconFilePath));
sanitizeProps<ContinueWatchingProps>({
iconFilePath,
titleId: "",
titleName: "",
imageFilePath: "",
watchedEpisodes: 0,
totalEpisodes: 0,
isMovie: false,
}),
);
return; return;
} }
@@ -206,18 +243,7 @@ async function refreshUpcoming(
}); });
if (items.length === 0) { if (items.length === 0) {
widget.updateSnapshot( widget.updateSnapshot(emptyUpcomingProps(iconFilePath));
sanitizeProps<UpcomingProps>({
iconFilePath,
titleId: "",
titleName: "",
imageFilePath: "",
titleType: "tv",
episodeCount: 0,
dateLabel: "",
episodeLabel: "",
}),
);
return; return;
} }