From 34b9ae362061b22a20d6b84ea74dfa451b74e939 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Tue, 24 Mar 2026 11:13:09 -0400 Subject: [PATCH] fix(native): fix widget image management and add tests - Fix `copyBundledAsset` to accept a URI instead of a named bundle asset, resolving via `resolveAssetURL` with support for remote downloads - Read app group identifier from Info.plist (`ExpoWidgetsAppGroupIdentifier`) instead of a hardcoded string - Add `pruneWidgetImages(maxAgeSeconds)` to age-based cache cleanup, skipping the widget icon file - Add `normalizedFileName` helper to sanitize cache keys and a `log` helper with consistent prefix - Extract `getWidgetIconAsset()` into `widget-assets.ts` so the asset require is testable in isolation - Add Vitest config for the native app and unit tests covering widget data-fetch, image download, and prune logic --- .github/workflows/claude-code-review.yml | 15 +- .github/workflows/claude.yml | 2 +- .../modules/sofa-widgets-support/index.ts | 9 +- .../ios/SofaWidgetsSupportModule.swift | 122 ++++++++-- .../src/SofaWidgetsSupportModule.ts | 9 +- apps/native/package.json | 3 +- apps/native/src/lib/widget-assets.ts | 3 + apps/native/src/lib/widgets.test.ts | 212 +++++++++++++++++ apps/native/src/lib/widgets.ts | 216 ++++++++++++------ apps/native/src/widgets/continue-watching.tsx | 7 +- apps/native/src/widgets/upcoming.tsx | 11 +- apps/native/vitest.config.ts | 15 ++ 12 files changed, 522 insertions(+), 102 deletions(-) create mode 100644 apps/native/src/lib/widget-assets.ts create mode 100644 apps/native/src/lib/widgets.test.ts create mode 100644 apps/native/vitest.config.ts diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index a639c3f..2b06373 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,11 +12,12 @@ on: jobs: claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + # Skip for bot PRs and fork PRs due to OIDC token limitations in pull_request_target + # Fork PRs fail because the actor in OIDC context is the fork contributor (read-only) + if: | + github.event.pull_request.draft == false && + github.event.pull_request.user.type != 'Bot' && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: @@ -41,3 +42,7 @@ jobs: prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}" # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options + track_progress: true + claude_args: | + --model 'claude-opus-4-6' + --allowedTools 'mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 9471a05..08e2eba 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -46,4 +46,4 @@ jobs: # Optional: Add claude_args to customize behavior and configuration # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' + claude_args: "--model claude-opus-4-6" diff --git a/apps/native/modules/sofa-widgets-support/index.ts b/apps/native/modules/sofa-widgets-support/index.ts index e6dfbc5..0c17ab6 100644 --- a/apps/native/modules/sofa-widgets-support/index.ts +++ b/apps/native/modules/sofa-widgets-support/index.ts @@ -9,12 +9,17 @@ export async function downloadWidgetImage(url: string, key: string): Promise { +export async function copyBundledAsset(assetUri: string, key: string): Promise { if (Platform.OS !== "ios") return null; - return getModule().copyBundledAsset(assetName, key); + return getModule().copyBundledAsset(assetUri, key); } export async function clearWidgetImages(): Promise { if (Platform.OS !== "ios") return; return getModule().clearWidgetImages(); } + +export async function pruneWidgetImages(maxAgeSeconds: number): Promise { + if (Platform.OS !== "ios") return; + return getModule().pruneWidgetImages(maxAgeSeconds); +} diff --git a/apps/native/modules/sofa-widgets-support/ios/SofaWidgetsSupportModule.swift b/apps/native/modules/sofa-widgets-support/ios/SofaWidgetsSupportModule.swift index 57fe8fb..0d5f6d7 100644 --- a/apps/native/modules/sofa-widgets-support/ios/SofaWidgetsSupportModule.swift +++ b/apps/native/modules/sofa-widgets-support/ios/SofaWidgetsSupportModule.swift @@ -1,8 +1,11 @@ import ExpoModulesCore +import Foundation import UIKit -private let groupIdentifier = "group.com.jakejarvis.sofa" private let imageDirectory = "widget_images" +private let widgetIconKey = "sofa_icon.png" +private let infoPlistAppGroupKey = "ExpoWidgetsAppGroupIdentifier" +private let logPrefix = "[SofaWidgetsSupport]" public class SofaWidgetsSupportModule: Module { public func definition() -> ModuleDefinition { @@ -10,20 +13,20 @@ public class SofaWidgetsSupportModule: Module { AsyncFunction("downloadWidgetImage") { (url: String, key: String) -> String? in guard let imageUrl = URL(string: url) else { + self.log("Invalid widget image URL: \(url)") return nil } - guard let directoryUrl = self.ensureImageDirectory() else { + guard let destinationUrl = self.destinationURL(for: key) else { return nil } - let destinationUrl = directoryUrl.appendingPathComponent(key) - let (data, response) = try await URLSession.shared.data(from: imageUrl) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + self.log("Failed to download widget image: \(url)") return nil } @@ -43,46 +46,97 @@ public class SofaWidgetsSupportModule: Module { return destinationUrl.absoluteString } - AsyncFunction("copyBundledAsset") { (assetName: String, key: String) -> String? in - guard let directoryUrl = self.ensureImageDirectory() else { + AsyncFunction("copyBundledAsset") { (assetUri: String, key: String) -> String? in + guard let destinationUrl = self.destinationURL(for: key) else { return nil } - let destinationUrl = directoryUrl.appendingPathComponent(key) - // Skip if already copied if FileManager.default.fileExists(atPath: destinationUrl.path) { return destinationUrl.absoluteString } - guard let image = UIImage(named: assetName) else { + guard let sourceUrl = self.resolveAssetURL(assetUri) else { + self.log("Unable to resolve widget asset URI: \(assetUri)") return nil } - guard let pngData = image.pngData() else { - return nil + let data: Data + if sourceUrl.isFileURL { + data = try Data(contentsOf: sourceUrl) + } else { + let (downloadedData, response) = try await URLSession.shared.data(from: sourceUrl) + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { + self.log("Failed to download widget asset: \(assetUri)") + return nil + } + data = downloadedData } - try pngData.write(to: destinationUrl, options: .atomic) + try data.write(to: destinationUrl, options: .atomic) return destinationUrl.absoluteString } AsyncFunction("clearWidgetImages") { - guard let containerUrl = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: groupIdentifier - ) else { + guard let directoryUrl = self.imageDirectoryURL() else { return } - let directoryUrl = containerUrl.appendingPathComponent(imageDirectory) try? FileManager.default.removeItem(at: directoryUrl) } + + AsyncFunction("pruneWidgetImages") { (maxAgeSeconds: Double) in + guard maxAgeSeconds > 0 else { + return + } + + guard let directoryUrl = self.imageDirectoryURL() else { + return + } + + let fileManager = FileManager.default + let cutoffDate = Date().addingTimeInterval(-maxAgeSeconds) + let resourceKeys: Set = [.contentModificationDateKey] + + let fileUrls = try fileManager.contentsOfDirectory( + at: directoryUrl, + includingPropertiesForKeys: Array(resourceKeys), + options: [.skipsHiddenFiles] + ) + + for fileUrl in fileUrls { + if fileUrl.lastPathComponent == widgetIconKey { + continue + } + + let modifiedAt = try fileUrl.resourceValues(forKeys: resourceKeys).contentModificationDate + ?? .distantPast + if modifiedAt < cutoffDate { + try? fileManager.removeItem(at: fileUrl) + } + } + } } - private func ensureImageDirectory() -> URL? { + private func appGroupIdentifier() -> String? { + guard let identifier = Bundle.main.object( + forInfoDictionaryKey: infoPlistAppGroupKey + ) as? String, !identifier.isEmpty else { + log("Missing \(infoPlistAppGroupKey) in Info.plist") + return nil + } + return identifier + } + + private func imageDirectoryURL() -> URL? { + guard let groupIdentifier = appGroupIdentifier() else { + return nil + } + guard let containerUrl = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: groupIdentifier ) else { + log("Unable to access app group container: \(groupIdentifier)") return nil } @@ -94,6 +148,40 @@ public class SofaWidgetsSupportModule: Module { return directoryUrl } + private func destinationURL(for key: String) -> URL? { + guard let directoryUrl = imageDirectoryURL() else { + return nil + } + + return directoryUrl.appendingPathComponent(normalizedFileName(key)) + } + + private func resolveAssetURL(_ assetUri: String) -> URL? { + if assetUri.isEmpty { + return nil + } + + if let url = URL(string: assetUri), url.scheme != nil { + return url + } + + return URL(fileURLWithPath: assetUri) + } + + private func normalizedFileName(_ key: String) -> String { + let fallback = UUID().uuidString + let source = key.isEmpty ? fallback : key + let allowedCharacters = CharacterSet.alphanumerics.union( + CharacterSet(charactersIn: "._-") + ) + let normalized = source.components(separatedBy: allowedCharacters.inverted).joined(separator: "_") + return normalized.isEmpty ? fallback : normalized + } + + private func log(_ message: String) { + print("\(logPrefix) \(message)") + } + private func resizeImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage { let size = image.size let scale = min(maxDimension / size.width, maxDimension / size.height, 1.0) diff --git a/apps/native/modules/sofa-widgets-support/src/SofaWidgetsSupportModule.ts b/apps/native/modules/sofa-widgets-support/src/SofaWidgetsSupportModule.ts index d2d6c66..ea85cbf 100644 --- a/apps/native/modules/sofa-widgets-support/src/SofaWidgetsSupportModule.ts +++ b/apps/native/modules/sofa-widgets-support/src/SofaWidgetsSupportModule.ts @@ -1,3 +1,10 @@ import { requireNativeModule } from "expo"; -export default requireNativeModule("SofaWidgetsSupport"); +type SofaWidgetsSupportModuleType = { + downloadWidgetImage(url: string, key: string): Promise; + copyBundledAsset(assetUri: string, key: string): Promise; + clearWidgetImages(): Promise; + pruneWidgetImages(maxAgeSeconds: number): Promise; +}; + +export default requireNativeModule("SofaWidgetsSupport"); diff --git a/apps/native/package.json b/apps/native/package.json index fd327b1..8a0b3ca 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -12,7 +12,8 @@ "lint": "oxlint", "format": "oxfmt --config ../../.oxfmtrc.json", "format:check": "oxfmt --check --config ../../.oxfmtrc.json", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@better-auth/expo": "catalog:", diff --git a/apps/native/src/lib/widget-assets.ts b/apps/native/src/lib/widget-assets.ts new file mode 100644 index 0000000..f394a38 --- /dev/null +++ b/apps/native/src/lib/widget-assets.ts @@ -0,0 +1,3 @@ +export function getWidgetIconAsset() { + return require("../../assets/widget/sofa-icon.png"); +} diff --git a/apps/native/src/lib/widgets.test.ts b/apps/native/src/lib/widgets.test.ts new file mode 100644 index 0000000..1a692fe --- /dev/null +++ b/apps/native/src/lib/widgets.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const CACHED_WIDGET_IMAGE_RE = /^file:\/\/\/group\/cw_/; +const platform = { OS: "ios" }; +const resolveAssetSource = vi.fn(() => ({ uri: "file:///tmp/sofa-icon.png" })); +const continueWatching = vi.fn(); +const upcoming = vi.fn(); +const downloadWidgetImage = vi.fn(async (_url: string, key: string) => `file:///group/${key}`); +const copyBundledAsset = vi.fn(async (_assetUri: string, key: string) => `file:///group/${key}`); +const pruneWidgetImages = vi.fn(async () => undefined); +const getWidgetIconAsset = vi.fn(() => "widget-icon-asset"); + +const continueWatchingWidget = { + updateSnapshot: vi.fn(), + updateTimeline: vi.fn(), +}; + +const upcomingWidget = { + updateSnapshot: vi.fn(), + updateTimeline: vi.fn(), +}; + +vi.mock("react-native", () => ({ + Image: { + resolveAssetSource, + }, + Platform: platform, +})); + +vi.mock("@/lib/widget-assets", () => ({ + getWidgetIconAsset, +})); + +vi.mock("@/lib/orpc", () => ({ + client: { + dashboard: { + continueWatching, + upcoming, + }, + }, +})); + +vi.mock("@/lib/server", () => ({ + resolveUrl: (path: string | null) => (path ? `https://sofa.test${path}` : null), +})); + +vi.mock("../../modules/sofa-widgets-support", () => ({ + copyBundledAsset, + downloadWidgetImage, + pruneWidgetImages, +})); + +vi.mock("@/widgets/continue-watching", () => ({ + default: continueWatchingWidget, +})); + +vi.mock("@/widgets/upcoming", () => ({ + default: upcomingWidget, +})); + +async function loadWidgetsModule() { + vi.resetModules(); + return import("./widgets"); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); + platform.OS = "ios"; + continueWatching.mockResolvedValue({ items: [] }); + upcoming.mockResolvedValue({ items: [] }); + downloadWidgetImage.mockImplementation( + async (_url: string, key: string) => `file:///group/${key}`, + ); + copyBundledAsset.mockImplementation( + async (_assetUri: string, key: string) => `file:///group/${key}`, + ); + pruneWidgetImages.mockResolvedValue(undefined); + resolveAssetSource.mockReturnValue({ uri: "file:///tmp/sofa-icon.png" }); + getWidgetIconAsset.mockReturnValue("widget-icon-asset"); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("refreshWidgets", () => { + test("uses a fresh widget image path on each refresh cycle", async () => { + continueWatching.mockResolvedValue({ + items: [ + { + title: { + id: "title-1", + title: "Severance", + backdropPath: "/backdrop-1.jpg", + }, + nextEpisode: { + seasonNumber: 2, + episodeNumber: 3, + stillPath: "/still-1.jpg", + }, + watchedEpisodes: 2, + totalEpisodes: 10, + }, + ], + }); + + const { refreshWidgets } = await loadWidgetsModule(); + + await refreshWidgets(); + const firstPath = + continueWatchingWidget.updateTimeline.mock.calls[0]?.[0]?.[0]?.props?.imageFilePath; + + continueWatchingWidget.updateTimeline.mockClear(); + vi.advanceTimersByTime(1000); + + await refreshWidgets(); + const secondPath = + continueWatchingWidget.updateTimeline.mock.calls[0]?.[0]?.[0]?.props?.imageFilePath; + + expect(firstPath).toMatch(CACHED_WIDGET_IMAGE_RE); + expect(secondPath).toMatch(CACHED_WIDGET_IMAGE_RE); + expect(firstPath).not.toBe(secondPath); + expect(copyBundledAsset).toHaveBeenCalledWith("file:///tmp/sofa-icon.png", "sofa_icon.png"); + expect(pruneWidgetImages).toHaveBeenCalledWith(21600); + }); + + test("publishes a timeline even when one widget image download fails", async () => { + continueWatching.mockResolvedValue({ + items: [ + { + title: { + id: "title-1", + title: "The Pitt", + backdropPath: "/fallback.jpg", + }, + nextEpisode: { + seasonNumber: 1, + episodeNumber: 5, + stillPath: "/bad.jpg", + }, + watchedEpisodes: 4, + totalEpisodes: 12, + }, + { + title: { + id: "title-2", + title: "Andor", + backdropPath: "/backdrop-2.jpg", + }, + nextEpisode: { + seasonNumber: 2, + episodeNumber: 1, + stillPath: "/good.jpg", + }, + watchedEpisodes: 8, + totalEpisodes: 12, + }, + ], + }); + + downloadWidgetImage.mockImplementation(async (url: string, key: string) => { + if (url.includes("/bad.jpg")) { + throw new Error("download failed"); + } + return `file:///group/${key}`; + }); + + const { refreshWidgets } = await loadWidgetsModule(); + await refreshWidgets(); + + const entries = continueWatchingWidget.updateTimeline.mock.calls[0]?.[0]; + expect(continueWatchingWidget.updateTimeline).toHaveBeenCalledTimes(1); + expect(entries).toHaveLength(2); + expect(entries[0]?.props?.imageFilePath).toBe(""); + expect(entries[1]?.props?.imageFilePath).toMatch(CACHED_WIDGET_IMAGE_RE); + }); + + test("passes the copied icon path into empty widget states", async () => { + const { refreshWidgets } = await loadWidgetsModule(); + await refreshWidgets(); + + expect(continueWatchingWidget.updateSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + iconFilePath: "file:///group/sofa_icon.png", + titleName: "", + }), + ); + expect(upcomingWidget.updateSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + iconFilePath: "file:///group/sofa_icon.png", + titleName: "", + }), + ); + }); + + test("is a no-op outside iOS", async () => { + platform.OS = "android"; + + const { refreshWidgets } = await loadWidgetsModule(); + await refreshWidgets(); + + expect(continueWatching).not.toHaveBeenCalled(); + expect(upcoming).not.toHaveBeenCalled(); + expect(downloadWidgetImage).not.toHaveBeenCalled(); + expect(copyBundledAsset).not.toHaveBeenCalled(); + expect(pruneWidgetImages).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/native/src/lib/widgets.ts b/apps/native/src/lib/widgets.ts index 288e75c..1e8683c 100644 --- a/apps/native/src/lib/widgets.ts +++ b/apps/native/src/lib/widgets.ts @@ -1,20 +1,48 @@ -import { Platform } from "react-native"; +import { Image, Platform } from "react-native"; import { client } from "@/lib/orpc"; import { resolveUrl } from "@/lib/server"; +import { getWidgetIconAsset } from "@/lib/widget-assets"; import type { ContinueWatchingProps } from "@/widgets/continue-watching"; import type { UpcomingProps } from "@/widgets/upcoming"; -import { copyBundledAsset, downloadWidgetImage } from "../../modules/sofa-widgets-support"; +import { + copyBundledAsset, + downloadWidgetImage, + pruneWidgetImages, +} from "../../modules/sofa-widgets-support"; + +/** + * Strip null/undefined values from widget props before passing to the native module. + * iOS UserDefaults (used by expo-widgets to store timeline entries) does not support + * NSNull — any null or undefined value in the props dictionary will throw an ObjC + * NSInvalidArgumentException that surfaces as "Exception in HostFunction: ". + */ +function sanitizeProps(props: { [K in keyof T]: T[K] | null }): T { + const clean = {} as Record; + for (const [key, value] of Object.entries(props)) { + if (value != null) { + clean[key] = value; + } + } + return clean as T; +} /** Resolve a potentially-relative image path to an absolute URL and download it. */ -async function downloadImage(path: string | null, key: string): Promise { +async function downloadImage(path: string | null, key: string): Promise { const url = resolveUrl(path); - if (!url) return null; - return downloadWidgetImage(url, key); + if (!url) return ""; + try { + return (await downloadWidgetImage(url, key)) ?? ""; + } catch (error) { + console.warn(`[Widgets] Failed to cache image (${key}):`, error); + return ""; + } } const TIMELINE_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes (iOS minimum) +const IMAGE_PRUNE_MAX_AGE_SECONDS = 6 * 60 * 60; +const WIDGET_ICON_KEY = "sofa_icon.png"; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; @@ -38,13 +66,51 @@ function formatEpisodeLabel(item: { return "TV"; } -let iconFilePath: string | null = null; +let refreshSequence = 0; -async function ensureIcon(): Promise { - if (!iconFilePath) { - iconFilePath = await copyBundledAsset("sofa-icon", "sofa_icon"); +function hashString(value: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + +function nextRefreshToken(prefix: string): string { + refreshSequence += 1; + return `${prefix}_${Date.now().toString(36)}_${refreshSequence.toString(36)}`; +} + +function buildImageKey( + prefix: string, + refreshToken: string, + parts: Array, +): string { + const identity = parts + .filter((part): part is string | number => part != null && part !== "") + .join("|"); + return `${prefix}_${refreshToken}_${hashString(identity)}.jpg`; +} + +function resolveWidgetIconUri(): string | null { + const source = Image.resolveAssetSource(getWidgetIconAsset()); + return source?.uri ?? null; +} + +async function ensureIcon(): Promise { + const iconUri = resolveWidgetIconUri(); + if (!iconUri) { + console.warn("[Widgets] Failed to resolve widget icon asset URI"); + return ""; + } + + try { + return (await copyBundledAsset(iconUri, WIDGET_ICON_KEY)) ?? ""; + } catch (error) { + console.warn("[Widgets] Failed to cache widget icon:", error); + return ""; } - return iconFilePath; } export async function refreshWidgets(): Promise { @@ -56,57 +122,69 @@ export async function refreshWidgets(): Promise { import("@/widgets/upcoming"), ]); - await ensureIcon(); + const iconFilePath = await ensureIcon(); await Promise.all([ - refreshContinueWatching(ContinueWatchingWidget), - refreshUpcoming(UpcomingWidget), + refreshContinueWatching(ContinueWatchingWidget, iconFilePath), + refreshUpcoming(UpcomingWidget, iconFilePath), ]); + + try { + await pruneWidgetImages(IMAGE_PRUNE_MAX_AGE_SECONDS); + } catch (error) { + console.warn("[Widgets] Failed to prune cached images:", error); + } } async function refreshContinueWatching( widget: Awaited["default"], + iconFilePath: string, ): Promise { try { const { items } = await client.dashboard.continueWatching(); if (items.length === 0) { - widget.updateSnapshot({ - isEmpty: true, - iconFilePath, - titleId: "", - titleName: "", - imageFilePath: null, - watchedEpisodes: 0, - totalEpisodes: 0, - isMovie: false, - }); + widget.updateSnapshot( + sanitizeProps({ + iconFilePath, + titleId: "", + titleName: "", + imageFilePath: "", + watchedEpisodes: 0, + totalEpisodes: 0, + isMovie: false, + }), + ); return; } const top5 = items.slice(0, 5); + const refreshToken = nextRefreshToken("cw"); const entries = await Promise.all( top5.map(async (item, index) => { - const imageKey = `cw_${index}`; const imagePath = item.nextEpisode?.stillPath ?? item.title.backdropPath; + const imageKey = buildImageKey("cw", refreshToken, [ + item.title.id, + item.nextEpisode?.seasonNumber, + item.nextEpisode?.episodeNumber, + imagePath, + index, + ]); const imageFilePath = await downloadImage(imagePath, imageKey); - const props: ContinueWatchingProps = { - titleId: item.title.id, - titleName: item.title.title, - imageFilePath, - iconFilePath: null, - seasonNumber: item.nextEpisode?.seasonNumber, - episodeNumber: item.nextEpisode?.episodeNumber, - watchedEpisodes: item.watchedEpisodes, - totalEpisodes: item.totalEpisodes, - isMovie: !item.nextEpisode, - isEmpty: false, - }; - return { date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS), - props, + props: sanitizeProps({ + titleId: item.title.id, + titleName: item.title.title, + imageFilePath, + iconFilePath, + seasonNumber: item.nextEpisode?.seasonNumber, + episodeNumber: item.nextEpisode?.episodeNumber, + watchedEpisodes: item.watchedEpisodes, + totalEpisodes: item.totalEpisodes, + isMovie: !item.nextEpisode, + }), }; }), ); @@ -119,6 +197,7 @@ async function refreshContinueWatching( async function refreshUpcoming( widget: Awaited["default"], + iconFilePath: string, ): Promise { try { const { items } = await client.dashboard.upcoming({ @@ -127,44 +206,51 @@ async function refreshUpcoming( }); if (items.length === 0) { - widget.updateSnapshot({ - isEmpty: true, - iconFilePath, - titleId: "", - titleName: "", - imageFilePath: null, - titleType: "tv", - episodeCount: 0, - dateLabel: "", - episodeLabel: "", - }); + widget.updateSnapshot( + sanitizeProps({ + iconFilePath, + titleId: "", + titleName: "", + imageFilePath: "", + titleType: "tv", + episodeCount: 0, + dateLabel: "", + episodeLabel: "", + }), + ); return; } const top5 = items.slice(0, 5); + const refreshToken = nextRefreshToken("up"); const entries = await Promise.all( top5.map(async (item, index) => { - const imageKey = `up_${index}`; + const imageKey = buildImageKey("up", refreshToken, [ + item.titleId, + item.date, + item.seasonNumber, + item.episodeNumber, + item.titleType, + item.backdropPath, + index, + ]); const imageFilePath = await downloadImage(item.backdropPath, imageKey); - const props: UpcomingProps = { - titleId: item.titleId, - titleName: item.titleName, - imageFilePath, - iconFilePath: null, - titleType: item.titleType, - seasonNumber: item.seasonNumber, - episodeNumber: item.episodeNumber, - episodeCount: item.episodeCount, - dateLabel: formatShortDate(item.date), - episodeLabel: formatEpisodeLabel(item), - isEmpty: false, - }; - return { date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS), - props, + props: sanitizeProps({ + titleId: item.titleId, + titleName: item.titleName, + imageFilePath, + iconFilePath, + titleType: item.titleType, + seasonNumber: item.seasonNumber, + episodeNumber: item.episodeNumber, + episodeCount: item.episodeCount, + dateLabel: formatShortDate(item.date), + episodeLabel: formatEpisodeLabel(item), + }), }; }), ); diff --git a/apps/native/src/widgets/continue-watching.tsx b/apps/native/src/widgets/continue-watching.tsx index 327beed..0700b94 100644 --- a/apps/native/src/widgets/continue-watching.tsx +++ b/apps/native/src/widgets/continue-watching.tsx @@ -14,20 +14,19 @@ import { createWidget, type WidgetEnvironment } from "expo-widgets"; export type ContinueWatchingProps = { titleId: string; titleName: string; - imageFilePath: string | null; - iconFilePath: string | null; + imageFilePath: string; + iconFilePath: string; seasonNumber?: number; episodeNumber?: number; watchedEpisodes: number; totalEpisodes: number; isMovie: boolean; - isEmpty: boolean; }; const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnvironment) => { "widget"; - if (props.isEmpty) { + if (!props.titleName) { return ( { "widget"; - if (props.isEmpty) { + if (!props.titleName) { return (