diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 2b063737..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # 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: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: "https://github.com/anthropics/claude-code.git" - plugins: "code-review@claude-code-plugins" - 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 deleted file mode 100644 index 08e2eba2..00000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # 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: "--model claude-opus-4-6" diff --git a/.gitignore b/.gitignore index 5341e15c..389d7aee 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules # Build outputs dist build +.build *.tsbuildinfo .tanstack diff --git a/.oxlintrc.json b/.oxlintrc.json index 967e0ca5..c8a5db0a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,7 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["oxc", "eslint", "typescript", "react", "import", "unicorn", "vitest", "jsx-a11y"], - "jsPlugins": ["@e18e/eslint-plugin"], "env": { "builtin": true }, @@ -15,6 +14,10 @@ "react/style-prop-object": "off", "import/no-unassigned-import": "off", "import/no-named-as-default-member": "off", + "no-underscore-dangle": [ + "warn", + { "allow": ["__APP_VERSION__", "__GIT_COMMIT_SHA__"] } + ], "oxc/no-barrel-file": ["warn", { "threshold": 0 }], "unicorn/no-array-sort": "off", "unicorn/consistent-function-scoping": "off", @@ -22,23 +25,7 @@ "no-await-in-loop": "off", "react/no-array-index-key": "off", "react/jsx-no-useless-fragment": "error", - "react/rules-of-hooks": "error", - "e18e/prefer-array-at": "error", - "e18e/prefer-array-fill": "error", - "e18e/prefer-array-from-map": "error", - "e18e/prefer-array-some": "error", - "e18e/prefer-array-to-reversed": "error", - "e18e/prefer-array-to-sorted": "error", - "e18e/prefer-array-to-spliced": "error", - "e18e/prefer-date-now": "error", - "e18e/prefer-includes": "error", - "e18e/prefer-nullish-coalescing": "error", - "e18e/prefer-object-has-own": "error", - "e18e/prefer-regex-test": "error", - "e18e/prefer-spread-syntax": "off", - "e18e/prefer-static-regex": "error", - "e18e/prefer-timer-args": "error", - "e18e/prefer-url-canparse": "error" + "react/rules-of-hooks": "error" }, "ignorePatterns": ["node_modules", "dist", "build", "docs", "**/routeTree.gen.ts"] } diff --git a/apps/native/app.json b/apps/native/app.json index c02d3ad5..05f38227 100644 --- a/apps/native/app.json +++ b/apps/native/app.json @@ -10,6 +10,7 @@ "config": { "usesNonExemptEncryption": false }, + "deploymentTarget": "16.4", "icon": "./assets/images/sofa.icon", "bundleIdentifier": "com.jakejarvis.sofa", "appleTeamId": "B5ZWKBCUTU", @@ -65,14 +66,6 @@ "package": "com.jakejarvis.sofa" }, "plugins": [ - [ - "expo-build-properties", - { - "ios": { - "deploymentTarget": "16.2" - } - } - ], "expo-router", "expo-secure-store", [ diff --git a/apps/native/package.json b/apps/native/package.json index 825ddda8..e4a7dda3 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -17,76 +17,70 @@ }, "dependencies": { "@better-auth/expo": "catalog:", - "@expo/metro-runtime": "55.0.6", - "@expo/ui": "55.0.5", - "@formatjs/intl-datetimeformat": "7.3.1", - "@formatjs/intl-getcanonicallocales": "3.2.2", - "@formatjs/intl-locale": "5.3.1", - "@formatjs/intl-numberformat": "9.3.1", - "@formatjs/intl-pluralrules": "6.3.1", - "@formatjs/intl-relativetimeformat": "12.3.1", + "@expo/material-symbols": "0.1.1", + "@expo/metro-runtime": "~57.0.3", + "@expo/ui": "~57.0.4", + "@formatjs/intl-datetimeformat": "7.4.9", + "@formatjs/intl-getcanonicallocales": "3.2.10", + "@formatjs/intl-locale": "5.3.9", + "@formatjs/intl-numberformat": "9.3.11", + "@formatjs/intl-pluralrules": "6.3.10", + "@formatjs/intl-relativetimeformat": "12.3.10", "@lingui/core": "catalog:", "@lingui/react": "catalog:", "@orpc/client": "catalog:", "@orpc/contract": "catalog:", "@orpc/tanstack-query": "catalog:", - "@react-native-menu/menu": "2.0.0", - "@react-navigation/elements": "2.9.11", - "@react-navigation/native": "7.1.34", - "@sentry/react-native": "8.6.0", + "@sentry/react-native": "~8.18.0", "@shopify/flash-list": "2.0.2", "@sofa/api": "workspace:*", "@sofa/i18n": "workspace:*", - "@tabler/icons-react-native": "3.40.0", - "@tanstack/query-async-storage-persister": "5.95.2", + "@tabler/icons-react-native": "3.44.0", + "@tanstack/query-async-storage-persister": "5.101.2", "@tanstack/react-form": "catalog:", "@tanstack/react-query": "catalog:", - "@tanstack/react-query-persist-client": "5.95.2", + "@tanstack/react-query-persist-client": "5.101.2", "better-auth": "catalog:", "burnt": "0.13.0", - "expo": "55.0.8", - "expo-application": "55.0.10", - "expo-build-properties": "55.0.10", - "expo-clipboard": "55.0.9", - "expo-constants": "55.0.9", - "expo-dev-client": "55.0.18", - "expo-device": "55.0.10", - "expo-font": "55.0.4", - "expo-glass-effect": "55.0.8", - "expo-haptics": "55.0.9", - "expo-image": "55.0.6", - "expo-image-picker": "55.0.13", - "expo-linear-gradient": "55.0.9", - "expo-linking": "55.0.8", - "expo-localization": "55.0.9", - "expo-network": "55.0.9", - "expo-router": "55.0.7", - "expo-secure-store": "55.0.9", - "expo-splash-screen": "55.0.12", - "expo-status-bar": "55.0.4", - "expo-system-ui": "55.0.10", - "expo-updates": "55.0.15", - "expo-web-browser": "55.0.10", - "expo-widgets": "55.0.7", - "posthog-react-native": "4.37.6", + "expo": "~57.0.4", + "expo-application": "~57.0.0", + "expo-clipboard": "~57.0.0", + "expo-constants": "~57.0.3", + "expo-dev-client": "~57.0.5", + "expo-device": "~57.0.0", + "expo-font": "~57.0.0", + "expo-glass-effect": "~57.0.0", + "expo-haptics": "~57.0.0", + "expo-image": "~57.0.0", + "expo-image-picker": "~57.0.2", + "expo-linear-gradient": "~57.0.0", + "expo-linking": "~57.0.2", + "expo-localization": "~57.0.0", + "expo-network": "~57.0.0", + "expo-router": "~57.0.4", + "expo-secure-store": "~57.0.0", + "expo-splash-screen": "~57.0.2", + "expo-status-bar": "~57.0.0", + "expo-system-ui": "~57.0.0", + "expo-updates": "~57.0.6", + "expo-web-browser": "~57.0.0", + "expo-widgets": "~57.0.3", + "posthog-react-native": "4.55.0", "react": "catalog:", "react-dom": "catalog:", - "react-native": "0.83.2", - "react-native-gesture-handler": "2.30.0", - "react-native-ios-context-menu": "3.2.0", - "react-native-ios-utilities": "5.2.0", - "react-native-keyboard-controller": "1.20.7", - "react-native-mmkv": "4.2.0", - "react-native-nitro-modules": "0.35.0", - "react-native-reanimated": "4.2.1", - "react-native-safe-area-context": "5.6.2", - "react-native-screens": "4.23.0", - "react-native-svg": "15.15.3", - "react-native-worklets": "0.7.2", + "react-native": "0.86.0", + "react-native-gesture-handler": "~2.32.0", + "react-native-keyboard-controller": "1.21.9", + "react-native-mmkv": "~4.3.2", + "react-native-nitro-modules": "~0.36.1", + "react-native-reanimated": "4.5.0", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.25.2", + "react-native-svg": "15.15.4", + "react-native-worklets": "0.10.0", "tailwind-merge": "catalog:", "tailwindcss": "catalog:", - "uniwind": "1.6.1", - "zeego": "3.0.6", + "uniwind": "1.10.0", "zod": "catalog:" }, "devDependencies": { @@ -95,5 +89,12 @@ "@types/node": "catalog:", "@types/react": "catalog:", "typescript": "catalog:" + }, + "expo": { + "install": { + "exclude": [ + "@sentry/react-native" + ] + } } } diff --git a/apps/native/src/app/(tabs)/(library)/_layout.tsx b/apps/native/src/app/(tabs)/(library)/_layout.tsx index db869f99..e85e73d3 100644 --- a/apps/native/src/app/(tabs)/(library)/_layout.tsx +++ b/apps/native/src/app/(tabs)/(library)/_layout.tsx @@ -27,6 +27,20 @@ function LibraryHeaderRight() { ); } +function renderLibraryHeaderRight() { + return ; +} + +function getLibraryHeaderRightItems() { + return [ + { + type: "custom" as const, + element: , + hidesSharedBackground: true, + }, + ]; +} + export default function LibraryLayout() { const { t } = useLingui(); const contentStyle = useResolveClassNames("bg-background"); @@ -40,13 +54,7 @@ export default function LibraryLayout() { [ - { - type: "custom" as const, - element: , - hidesSharedBackground: true, - }, - ], + unstable_headerRightItems: getLibraryHeaderRightItems, }} > @@ -76,7 +84,7 @@ export default function LibraryLayout() { screenOptions={{ contentStyle, headerTitleAlign: "left", - headerRight: () => , + headerRight: renderLibraryHeaderRight, }} > diff --git a/apps/native/src/app/(tabs)/(settings)/index.tsx b/apps/native/src/app/(tabs)/(settings)/index.tsx index 58ff0692..934dd0a1 100644 --- a/apps/native/src/app/(tabs)/(settings)/index.tsx +++ b/apps/native/src/app/(tabs)/(settings)/index.tsx @@ -1,3 +1,5 @@ +import { Icon } from "@expo/ui"; +import { MenuView } from "@expo/ui/community/menu"; import { plural } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import { @@ -38,7 +40,6 @@ import { } from "react-native"; import Animated, { FadeInDown } from "react-native-reanimated"; import { useCSSVariable } from "uniwind"; -import * as DropdownMenu from "zeego/dropdown-menu"; import { IntegrationsSection } from "@/components/settings/integrations-section"; import { SettingsRow } from "@/components/settings/settings-row"; @@ -66,6 +67,15 @@ const settingsContentContainerStyle = { paddingHorizontal: 16, }; +const changePhotoIcon = Icon.select({ + ios: "photo.on.rectangle.angled", + android: import("@expo/material-symbols/photo_library.xml"), +}); +const removePhotoIcon = Icon.select({ + ios: "trash", + android: import("@expo/material-symbols/delete.xml"), +}); + export default function SettingsScreen() { const { t, i18n } = useLingui(); const { push } = useRouter(); @@ -241,52 +251,49 @@ export default function SettingsScreen() { {hasAvatarImage ? ( - - - - - {isUploadingAvatar ? ( - - - - ) : ( - - )} - - - - - - - - pickAvatar()}> - - - Change Photo - - - removeAvatar.mutate()} - > - - - Remove Photo - - - - + { + if (nativeEvent.event === "change") { + pickAvatar(); + } else if (nativeEvent.event === "remove") { + removeAvatar.mutate(); + } + }} + > + + + {isUploadingAvatar ? ( + + + + ) : ( + + )} + + + + + + ) : ( ; + logoStyle?: StyleProp>; children: ReactNode; } diff --git a/apps/native/src/components/dashboard/stats-card.tsx b/apps/native/src/components/dashboard/stats-card.tsx index e1f4776c..23adc02c 100644 --- a/apps/native/src/components/dashboard/stats-card.tsx +++ b/apps/native/src/components/dashboard/stats-card.tsx @@ -1,7 +1,7 @@ +import { MenuView } from "@expo/ui/community/menu"; import { useLingui } from "@lingui/react/macro"; import type { Icon } from "@tabler/icons-react-native"; import { Pressable, View } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; import { ScaledIcon } from "@/components/ui/scaled-icon"; import { Sparkline } from "@/components/ui/sparkline"; @@ -86,29 +86,24 @@ export function StatsCard(props: StatsCardProps) { return ( - - - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)} - accessibilityRole="button" - > - - - - - - {periods.map((p) => ( - onPeriodChange(p)} - > - - {periodLabels[p]} - - ))} - - + ({ + id: value, + title: periodLabels[value], + state: value === period ? "on" : "off", + }))} + onPressAction={({ nativeEvent }) => { + const nextPeriod = periods.find((value) => value === nativeEvent.event); + if (nextPeriod) onPeriodChange(nextPeriod); + }} + > + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)} + accessibilityRole="button" + > + + + ); } diff --git a/apps/native/src/components/header-avatar.tsx b/apps/native/src/components/header-avatar.tsx index de614674..d207869c 100644 --- a/apps/native/src/components/header-avatar.tsx +++ b/apps/native/src/components/header-avatar.tsx @@ -1,7 +1,8 @@ +import { Icon } from "@expo/ui"; +import { MenuView } from "@expo/ui/community/menu"; import { useLingui } from "@lingui/react/macro"; import { useRouter } from "expo-router"; import { Alert, Pressable, View } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; import { Image } from "@/components/ui/image"; import { Text } from "@/components/ui/text"; @@ -9,6 +10,15 @@ import { queryClient } from "@/lib/query-client"; import { authClient } from "@/lib/server"; import * as Haptics from "@/utils/haptics"; +const settingsIcon = Icon.select({ + ios: "gear", + android: import("@expo/material-symbols/settings.xml"), +}); +const signOutIcon = Icon.select({ + ios: "rectangle.portrait.and.arrow.right", + android: import("@expo/material-symbols/logout.xml"), +}); + export function HeaderAvatar() { const { t } = useLingui(); const { data: session } = authClient.useSession(); @@ -19,66 +29,60 @@ export function HeaderAvatar() { const { user } = session; return ( - - - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)} - accessibilityRole="button" - accessibilityLabel={t`User menu`} - hitSlop={8} - > - - {user.image ? ( - - ) : ( - - - {user.name?.charAt(0)?.toUpperCase() ?? "?"} - - - )} - - - - - - navigate("/(tabs)/(settings)")}> - - {t`Settings`} - - - { - Alert.alert(t`Sign out`, t`Are you sure you want to sign out?`, [ - { text: t`Cancel`, style: "cancel" }, - { - text: t`Sign out`, - style: "destructive", - onPress: () => { - authClient.signOut(); - queryClient.clear(); - }, + { + if (nativeEvent.event === "settings") { + navigate("/(tabs)/(settings)"); + } else if (nativeEvent.event === "sign-out") { + Alert.alert(t`Sign out`, t`Are you sure you want to sign out?`, [ + { text: t`Cancel`, style: "cancel" }, + { + text: t`Sign out`, + style: "destructive", + onPress: () => { + authClient.signOut(); + queryClient.clear(); }, - ]); - }} - > - - {t`Sign out`} - - - + }, + ]); + } + }} + > + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)} + accessibilityRole="button" + accessibilityLabel={t`User menu`} + hitSlop={8} + > + + {user.image ? ( + + ) : ( + + + {user.name?.charAt(0)?.toUpperCase() ?? "?"} + + + )} + + + ); } diff --git a/apps/native/src/components/library/sort-menu.tsx b/apps/native/src/components/library/sort-menu.tsx index 3d64903f..58c549cc 100644 --- a/apps/native/src/components/library/sort-menu.tsx +++ b/apps/native/src/components/library/sort-menu.tsx @@ -1,8 +1,8 @@ +import { MenuView } from "@expo/ui/community/menu"; import { useLingui } from "@lingui/react/macro"; import { IconArrowsSort } from "@tabler/icons-react-native"; import { Pressable } from "react-native"; import { useCSSVariable } from "uniwind"; -import * as DropdownMenu from "zeego/dropdown-menu"; import { ScaledIcon } from "@/components/ui/scaled-icon"; import * as Haptics from "@/utils/haptics"; @@ -37,33 +37,27 @@ export function SortMenu({ sortBy, sortDirection, onSortChange }: SortMenuProps) ]; return ( - - - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)} - accessibilityRole="button" - accessibilityLabel={t`Sort`} - hitSlop={8} - > - - - - - - {sortOptions.map((option) => { - const isSelected = option.sortBy === sortBy && option.sortDirection === sortDirection; - return ( - onSortChange(option.sortBy, option.sortDirection)} - > - - {option.label} - - ); - })} - - + ({ + id: `${option.sortBy}-${option.sortDirection}`, + title: option.label, + state: option.sortBy === sortBy && option.sortDirection === sortDirection ? "on" : "off", + }))} + onPressAction={({ nativeEvent }) => { + const option = sortOptions.find( + (candidate) => `${candidate.sortBy}-${candidate.sortDirection}` === nativeEvent.event, + ); + if (option) onSortChange(option.sortBy, option.sortDirection); + }} + > + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)} + accessibilityRole="button" + accessibilityLabel={t`Sort`} + hitSlop={8} + > + + + ); } diff --git a/apps/native/src/components/navigation/native-tab-bar.tsx b/apps/native/src/components/navigation/native-tab-bar.tsx index e8012d6b..a3168f14 100644 --- a/apps/native/src/components/navigation/native-tab-bar.tsx +++ b/apps/native/src/components/navigation/native-tab-bar.tsx @@ -40,7 +40,7 @@ export function NativeTabBar() { {t`Settings`} - + {t`Search`} diff --git a/apps/native/src/components/navigation/tab-stack.tsx b/apps/native/src/components/navigation/tab-stack.tsx index 11ee2ad4..22614da4 100644 --- a/apps/native/src/components/navigation/tab-stack.tsx +++ b/apps/native/src/components/navigation/tab-stack.tsx @@ -4,6 +4,20 @@ import { useCSSVariable, useResolveClassNames } from "uniwind"; import { HeaderAvatar } from "@/components/header-avatar"; +function renderHeaderAvatar() { + return ; +} + +function getHeaderRightItems() { + return [ + { + type: "custom" as const, + element: , + hidesSharedBackground: true, + }, + ]; +} + export function TabStack({ title, children }: { title?: string; children?: ReactNode }) { const contentStyle = useResolveClassNames("bg-background"); const tintColor = useCSSVariable("--color-primary") as string; @@ -16,13 +30,7 @@ export function TabStack({ title, children }: { title?: string; children?: React [ - { - type: "custom" as const, - element: , - hidesSharedBackground: true, - }, - ], + unstable_headerRightItems: getHeaderRightItems, }} > {title ? ( @@ -55,7 +63,7 @@ export function TabStack({ title, children }: { title?: string; children?: React screenOptions={{ contentStyle, headerTitleAlign: "left", - headerRight: () => , + headerRight: renderHeaderAvatar, }} > {title ? ( diff --git a/apps/native/src/components/titles/season-accordion.tsx b/apps/native/src/components/titles/season-accordion.tsx index f89ab0ac..e40a0295 100644 --- a/apps/native/src/components/titles/season-accordion.tsx +++ b/apps/native/src/components/titles/season-accordion.tsx @@ -51,18 +51,18 @@ export function SeasonAccordion({ const [visibleCount, setVisibleCount] = useState(INITIAL_BATCH); useEffect(() => { - if (expanded) { - setVisibleCount(INITIAL_BATCH); - if (episodes.length > INITIAL_BATCH) { - const task = InteractionManager.runAfterInteractions(() => { - setVisibleCount(episodes.length); - }); - return () => task.cancel(); - } + if (expanded && episodes.length > INITIAL_BATCH) { + const task = InteractionManager.runAfterInteractions(() => { + setVisibleCount(episodes.length); + }); + return () => task.cancel(); } }, [expanded, episodes.length]); - const toggleExpanded = useCallback(() => setExpanded((v) => !v), []); + const toggleExpanded = useCallback(() => { + if (!expanded) setVisibleCount(INITIAL_BATCH); + setExpanded((v) => !v); + }, [expanded]); useEffect(() => { chevronRotation.set( diff --git a/apps/native/src/lib/mmkv.ts b/apps/native/src/lib/mmkv.ts index 051ff56f..849dd814 100644 --- a/apps/native/src/lib/mmkv.ts +++ b/apps/native/src/lib/mmkv.ts @@ -5,33 +5,33 @@ import { createMMKV } from "react-native-mmkv"; export const globalStorage = createMMKV(); // Server+user scoped storage — switches when scope changes -let _scopedStore: ReturnType | null = null; -let _currentScopeKey: string | null = null; +let scopedStore: ReturnType | null = null; +let currentScopeKey: string | null = null; const scopeChangeListeners: Array<() => void> = []; export function scopedStorage() { - if (!_scopedStore) throw new Error("Scoped storage not initialized"); - return _scopedStore; + if (!scopedStore) throw new Error("Scoped storage not initialized"); + return scopedStore; } export function hasScopedStorage(): boolean { - return _scopedStore !== null; + return scopedStore !== null; } export function getScopeKey(): string | null { - return _currentScopeKey; + return currentScopeKey; } export function setStorageScope(instanceId: string, userId: string) { - _currentScopeKey = `${instanceId}_${userId}`; - _scopedStore = createMMKV({ id: _currentScopeKey }); + currentScopeKey = `${instanceId}_${userId}`; + scopedStore = createMMKV({ id: currentScopeKey }); for (const listener of scopeChangeListeners) listener(); } export function clearStorageScope() { - _currentScopeKey = null; - _scopedStore = null; + currentScopeKey = null; + scopedStore = null; for (const listener of scopeChangeListeners) listener(); } @@ -43,11 +43,11 @@ export function onStorageScopeChange(callback: () => void): () => void { }; } -// Query persister — reads/writes through _scopedStore via closure +// Query persister — reads/writes through scopedStore via closure const scopedMmkvStorage = { - getItem: (key: string) => _scopedStore?.getString(key) ?? null, - setItem: (key: string, value: string) => _scopedStore?.set(key, value), - removeItem: (key: string) => void _scopedStore?.remove(key), + getItem: (key: string) => scopedStore?.getString(key) ?? null, + setItem: (key: string, value: string) => scopedStore?.set(key, value), + removeItem: (key: string) => void scopedStore?.remove(key), }; export const QUERY_CACHE_KEY = "REACT_QUERY_OFFLINE_CACHE"; diff --git a/apps/native/src/lib/server.ts b/apps/native/src/lib/server.ts index 0e3c81c0..5fa98b87 100644 --- a/apps/native/src/lib/server.ts +++ b/apps/native/src/lib/server.ts @@ -380,7 +380,7 @@ function getCachedSession(): CachedSessionData | null { } } -let _cachedSessionSeeded = false; +let cachedSessionSeeded = false; /** * Seed the Better Auth session atom from SecureStore before React renders. @@ -389,7 +389,7 @@ let _cachedSessionSeeded = false; export function initSession(): void { const cached = getCachedSession(); if (cached) { - _cachedSessionSeeded = true; + cachedSessionSeeded = true; const sessionAtom = authClient.$store.atoms.session; sessionAtom.set({ data: cached, @@ -402,11 +402,11 @@ export function initSession(): void { } export function wasCachedSessionSeeded(): boolean { - return _cachedSessionSeeded; + return cachedSessionSeeded; } export function clearCachedSessionSeeded(): void { - _cachedSessionSeeded = false; + cachedSessionSeeded = false; } // --------------------------------------------------------------------------- @@ -419,15 +419,15 @@ export function clearCachedSessionSeeded(): void { // Signals that the next session-loss redirect should go to the server-url // screen instead of login (set by the "Change Server" flow in settings). -let _serverChangeRequested = false; +let serverChangeRequested = false; export function requestServerChange(): void { - _serverChangeRequested = true; + serverChangeRequested = true; } export function consumeServerChangeRequest(): boolean { - const was = _serverChangeRequested; - _serverChangeRequested = false; + const was = serverChangeRequested; + serverChangeRequested = false; return was; } diff --git a/apps/native/src/lib/theme.ts b/apps/native/src/lib/theme.ts index b5aa67c7..e15bf522 100644 --- a/apps/native/src/lib/theme.ts +++ b/apps/native/src/lib/theme.ts @@ -1,4 +1,4 @@ -import { DarkTheme } from "@react-navigation/native"; +import { DarkTheme } from "expo-router/react-navigation"; export const sofaTheme = { ...DarkTheme, diff --git a/apps/native/src/lib/widgets.test.ts b/apps/native/src/lib/widgets.test.ts index b4ebe99c..f51f2805 100644 --- a/apps/native/src/lib/widgets.test.ts +++ b/apps/native/src/lib/widgets.test.ts @@ -1,23 +1,33 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const CACHED_WIDGET_IMAGE_RE = /^file:\/\/\/group\/cw_/; +type LibraryResult = { items: Array> }; +type SnapshotProps = Record; +type TimelineEntry = { props?: Record }; + 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 resolveAssetSource = vi.fn<() => { uri: string }>(() => ({ + uri: "file:///tmp/sofa-icon.png", +})); +const continueWatching = vi.fn<() => Promise>(); +const upcoming = vi.fn<() => Promise>(); +const downloadWidgetImage = vi.fn<(url: string, key: string) => Promise>( + async (_url: string, key: string) => `file:///group/${key}`, +); +const copyBundledAsset = vi.fn<(assetUri: string, key: string) => Promise>( + async (_assetUri: string, key: string) => `file:///group/${key}`, +); +const pruneWidgetImages = vi.fn<() => Promise>(async () => undefined); +const getWidgetIconAsset = vi.fn<() => string>(() => "widget-icon-asset"); const continueWatchingWidget = { - updateSnapshot: vi.fn(), - updateTimeline: vi.fn(), + updateSnapshot: vi.fn<(props: SnapshotProps) => void>(), + updateTimeline: vi.fn<(entries: TimelineEntry[]) => void>(), }; const upcomingWidget = { - updateSnapshot: vi.fn(), - updateTimeline: vi.fn(), + updateSnapshot: vi.fn<(props: SnapshotProps) => void>(), + updateTimeline: vi.fn<(entries: TimelineEntry[]) => void>(), }; vi.mock("react-native", () => ({ diff --git a/apps/native/tsconfig.json b/apps/native/tsconfig.json index ff8ba589..dd665e64 100644 --- a/apps/native/tsconfig.json +++ b/apps/native/tsconfig.json @@ -2,7 +2,6 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } diff --git a/apps/public-api/package.json b/apps/public-api/package.json index bd1f5414..338dd53b 100644 --- a/apps/public-api/package.json +++ b/apps/public-api/package.json @@ -12,9 +12,9 @@ "check-types": "tsc --noEmit" }, "dependencies": { - "@hono/zod-validator": "0.7.6", - "@vercel/firewall": "1.1.2", - "hono": "4.12.9", + "@hono/zod-validator": "0.8.0", + "@vercel/firewall": "1.2.1", + "hono": "4.12.28", "zod": "catalog:" }, "devDependencies": { diff --git a/apps/server/package.json b/apps/server/package.json index 27d3da85..14a876ad 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -24,8 +24,8 @@ "@sofa/db": "workspace:*", "@sofa/logger": "workspace:*", "@sofa/tmdb": "workspace:*", - "croner": "10.0.2-dev.2", - "hono": "4.12.9", + "croner": "10.0.2-dev.3", + "hono": "4.12.28", "zod": "catalog:" }, "devDependencies": { diff --git a/apps/server/src/cron.ts b/apps/server/src/cron.ts index 6caa51f2..f9c9106c 100644 --- a/apps/server/src/cron.ts +++ b/apps/server/src/cron.ts @@ -46,13 +46,13 @@ const DAY = 24 * 60 * 60 * 1000; const RATE_LIMIT_MS = 300; const globalForJobs = globalThis as unknown as { - _jobs: Map | undefined; + cronJobs: Map | undefined; }; -if (!globalForJobs._jobs) { - globalForJobs._jobs = new Map(); +if (!globalForJobs.cronJobs) { + globalForJobs.cronJobs = new Map(); } -const jobs = globalForJobs._jobs; +const jobs = globalForJobs.cronJobs; function schedule(name: string, cron: string, handler: () => Promise) { jobs.set( diff --git a/apps/web/package.json b/apps/web/package.json index d930a5d8..8cdf85aa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,9 +14,9 @@ "test": "vitest run" }, "dependencies": { - "@base-ui/react": "1.3.0", + "@base-ui/react": "1.6.0", "@fontsource-variable/dm-sans": "5.2.8", - "@fontsource-variable/geist-mono": "5.2.7", + "@fontsource-variable/geist-mono": "5.2.8", "@fontsource/dm-serif-display": "5.2.8", "@lingui/core": "catalog:", "@lingui/react": "catalog:", @@ -24,24 +24,24 @@ "@orpc/contract": "catalog:", "@orpc/tanstack-query": "catalog:", "@player.style/sutro": "0.2.1", - "@rolldown/plugin-babel": "0.2.2", + "@rolldown/plugin-babel": "0.2.3", "@sofa/api": "workspace:*", "@sofa/i18n": "workspace:*", - "@tabler/icons-react": "3.40.0", + "@tabler/icons-react": "3.44.0", "@tanstack/react-form": "catalog:", - "@tanstack/react-hotkeys": "0.8.1", + "@tanstack/react-hotkeys": "0.10.0", "@tanstack/react-query": "catalog:", - "@tanstack/react-router": "1.168.5", - "@tanstack/zod-adapter": "1.166.9", + "@tanstack/react-router": "1.170.17", + "@tanstack/zod-adapter": "1.167.0", "better-auth": "catalog:", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", - "jotai": "2.19.0", - "motion": "12.38.0", + "jotai": "2.20.1", + "motion": "12.42.2", "react": "catalog:", "react-dom": "catalog:", - "shadcn": "4.1.0", + "shadcn": "4.13.0", "sonner": "2.0.7", "tailwind-merge": "catalog:", "thumbhash": "catalog:", @@ -52,23 +52,23 @@ "@lingui/babel-plugin-lingui-macro": "catalog:", "@lingui/vite-plugin": "catalog:", "@sofa/tsconfig": "workspace:*", - "@tailwindcss/vite": "4.2.2", - "@tanstack/devtools-vite": "0.6.0", - "@tanstack/react-devtools": "0.10.0", - "@tanstack/react-query-devtools": "5.95.2", - "@tanstack/react-router-devtools": "1.166.11", - "@tanstack/router-plugin": "1.167.6", + "@tailwindcss/vite": "4.3.2", + "@tanstack/devtools-vite": "0.8.1", + "@tanstack/react-devtools": "0.10.8", + "@tanstack/react-query-devtools": "5.101.2", + "@tanstack/react-router-devtools": "1.167.0", + "@tanstack/router-plugin": "1.168.19", "@types/bun": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "6.0.1", + "@vitejs/plugin-react": "6.0.3", "@vitest/browser": "catalog:", "@vitest/browser-playwright": "catalog:", "babel-plugin-react-compiler": "1.0.0", "tailwindcss": "catalog:", "typescript": "catalog:", - "vite": "8.0.3", + "vite": "8.1.3", "vitest": "catalog:" } } diff --git a/apps/web/src/components/explore/filterable-title-row.tsx b/apps/web/src/components/explore/filterable-title-row.tsx index 1020f352..9289c221 100644 --- a/apps/web/src/components/explore/filterable-title-row.tsx +++ b/apps/web/src/components/explore/filterable-title-row.tsx @@ -25,6 +25,8 @@ interface TitleRowItem { } type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed"; +const EMPTY_STATUSES: Record = {}; +const EMPTY_PROGRESS: Record = {}; interface FilterableTitleRowProps { heading: string; @@ -42,8 +44,8 @@ export function FilterableTitleRow({ mediaType, defaultItems, genres, - userStatuses: initialStatuses = {}, - episodeProgress: initialProgress = {}, + userStatuses: initialStatuses = EMPTY_STATUSES, + episodeProgress: initialProgress = EMPTY_PROGRESS, }: FilterableTitleRowProps) { const [selectedGenre, setSelectedGenre] = useState(null); const scrollRef = useRef(null); diff --git a/apps/web/src/components/route-error.tsx b/apps/web/src/components/route-error.tsx index 6c343872..b8bf1a77 100644 --- a/apps/web/src/components/route-error.tsx +++ b/apps/web/src/components/route-error.tsx @@ -1,9 +1,11 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { IconAlertTriangle } from "@tabler/icons-react"; import type { ErrorComponentProps } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router"; export function RouteError({ reset }: ErrorComponentProps) { + const { t } = useLingui(); + return (
@@ -23,6 +25,7 @@ export function RouteError({ reset }: ErrorComponentProps) {