mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 01:05:42 -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 { 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]);
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user