mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 23:55:43 -04:00
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:
3 files changed
+110
-34
No files matched your search
@@ -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]);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const copyBundledAsset = vi.fn<(assetUri: string, key: string) => Promise<string
|
||||
async (_assetUri: string, key: string) => `file:///group/${key}`,
|
||||
);
|
||||
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 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();
|
||||
});
|
||||
});
|
||||
@@ -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<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> {
|
||||
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<ContinueWatchingProps>({
|
||||
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<UpcomingProps>({
|
||||
iconFilePath,
|
||||
titleId: "",
|
||||
titleName: "",
|
||||
imageFilePath: "",
|
||||
titleType: "tv",
|
||||
episodeCount: 0,
|
||||
dateLabel: "",
|
||||
episodeLabel: "",
|
||||
}),
|
||||
);
|
||||
widget.updateSnapshot(emptyUpcomingProps(iconFilePath));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user