diff --git a/apps/mobile/__tests__/useNotifications.test.ts b/apps/mobile/__tests__/useNotifications.test.ts index 02a065e3..941cc33c 100644 --- a/apps/mobile/__tests__/useNotifications.test.ts +++ b/apps/mobile/__tests__/useNotifications.test.ts @@ -1,6 +1,5 @@ import { renderHook, act } from '@testing-library/react-native'; import { useNotifications } from '../hooks/useNotifications'; -import { NotificationService } from '../services/NotificationService'; describe('useNotifications', () => { it('loads and counts unread notifications', async () => { diff --git a/apps/mobile/app/(tabs)/notifications.tsx b/apps/mobile/app/(tabs)/notifications.tsx index e74490ad..a0c3f355 100644 --- a/apps/mobile/app/(tabs)/notifications.tsx +++ b/apps/mobile/app/(tabs)/notifications.tsx @@ -4,7 +4,6 @@ */ import React, { useCallback, useEffect, useState } from 'react'; import { - ActivityIndicator, FlatList, RefreshControl, StyleSheet, diff --git a/apps/mobile/app/(tabs)/settings.tsx b/apps/mobile/app/(tabs)/settings.tsx index 48868d71..8cf190a7 100644 --- a/apps/mobile/app/(tabs)/settings.tsx +++ b/apps/mobile/app/(tabs)/settings.tsx @@ -4,7 +4,7 @@ * the biometric lock shipped in #333 could never be turned on. */ import React, { useState } from 'react'; -import { Alert, Clipboard, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { Alert, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { useRouter } from 'expo-router'; import * as LocalAuthentication from 'expo-local-authentication'; import { useBiometricLock } from '../../hooks/useBiometricLock'; @@ -77,13 +77,6 @@ export default function SettingsScreen() { } }; - const handleCopySeed = () => { - if (seedValue) { - Clipboard.setString(seedValue); - Alert.alert('Copied', 'Seed copied to clipboard. Remember to clear it after backup.'); - } - }; - const handleImportWallet = () => { if (!importSeed.trim()) { Alert.alert('Error', 'Please enter a secret seed.'); diff --git a/apps/mobile/app/escrow/release.tsx b/apps/mobile/app/escrow/release.tsx index b2429c96..593cc22a 100644 --- a/apps/mobile/app/escrow/release.tsx +++ b/apps/mobile/app/escrow/release.tsx @@ -5,7 +5,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, - Alert, StyleSheet, Text, TouchableOpacity, diff --git a/apps/mobile/components/CopyButton.tsx b/apps/mobile/components/CopyButton.tsx index 7a759165..da09022f 100644 --- a/apps/mobile/components/CopyButton.tsx +++ b/apps/mobile/components/CopyButton.tsx @@ -3,7 +3,7 @@ * Uses expo-clipboard for cross-platform clipboard access. */ import React, { useCallback, useState } from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, Text, TouchableOpacity } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import { showToast } from './Toast'; diff --git a/apps/mobile/components/RaiseDisputeModal.tsx b/apps/mobile/components/RaiseDisputeModal.tsx index ee4871ac..141fd018 100644 --- a/apps/mobile/components/RaiseDisputeModal.tsx +++ b/apps/mobile/components/RaiseDisputeModal.tsx @@ -117,11 +117,12 @@ export const RaiseDisputeModal: React.FC = ({ visible, o : f, ), ); - } catch (error: any) { + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Upload failed'; setUploadedFiles(prev => prev.map(f => f.id === file.id - ? { ...f, progress: 0, error: error.message || 'Upload failed', isRetrying: false } + ? { ...f, progress: 0, error: message, isRetrying: false } : f, ), ); diff --git a/apps/mobile/hooks/useDashboardCache.ts b/apps/mobile/hooks/useDashboardCache.ts index e9bfc6fd..8e48eae9 100644 --- a/apps/mobile/hooks/useDashboardCache.ts +++ b/apps/mobile/hooks/useDashboardCache.ts @@ -44,11 +44,11 @@ export function useDashboardCache( if (cached) { setData(cached.data); setUpdatedAt(cached.updatedAt); - - const age = - Date.now() - cached.updatedAt; - - setStale(age > 1000 * 60 * 30); + setStale(cached.stale); + } else { + setData(null); + setUpdatedAt(undefined); + setStale(false); } setLoading(false); @@ -75,4 +75,4 @@ export function useDashboardCache( stale, refresh: load, }; -} \ No newline at end of file +} diff --git a/apps/mobile/hooks/useEscrowDetailCache.ts b/apps/mobile/hooks/useEscrowDetailCache.ts index f2f4fbf5..7c4e3050 100644 --- a/apps/mobile/hooks/useEscrowDetailCache.ts +++ b/apps/mobile/hooks/useEscrowDetailCache.ts @@ -43,11 +43,11 @@ export function useEscrowDetailCache( if (cached) { setData(cached.data); setUpdatedAt(cached.updatedAt); - - const age = - Date.now() - cached.updatedAt; - - setStale(age > 1000 * 60 * 30); + setStale(cached.stale); + } else { + setData(null); + setUpdatedAt(undefined); + setStale(false); } return; @@ -76,4 +76,4 @@ export function useEscrowDetailCache( stale, refresh: load, }; -} \ No newline at end of file +} diff --git a/apps/mobile/hooks/useNetworkStatus.ts b/apps/mobile/hooks/useNetworkStatus.ts index 47f62486..e71688a2 100644 --- a/apps/mobile/hooks/useNetworkStatus.ts +++ b/apps/mobile/hooks/useNetworkStatus.ts @@ -4,18 +4,6 @@ */ import { useState, useEffect, useCallback } from 'react'; -interface NetInfoState { - isConnected: boolean | null; - isInternetReachable: boolean | null; - type: string; -} - -const DEFAULT_STATE: NetInfoState = { - isConnected: null, - isInternetReachable: null, - type: 'unknown', -}; - let NetInfoModule: typeof import('@react-native-community/netinfo') | null = null; try { diff --git a/apps/mobile/services/NotificationService.ts b/apps/mobile/services/NotificationService.ts index f2a4c368..f10a36bd 100644 --- a/apps/mobile/services/NotificationService.ts +++ b/apps/mobile/services/NotificationService.ts @@ -27,7 +27,7 @@ export class NotificationService { ]; } - static async markAsRead(id: string): Promise { + static async markAsRead(_id: string): Promise { // API call stub return true; } diff --git a/apps/mobile/services/api.ts b/apps/mobile/services/api.ts index 126afd6b..3fc90171 100644 --- a/apps/mobile/services/api.ts +++ b/apps/mobile/services/api.ts @@ -7,7 +7,7 @@ import { ReleaseMilestonePayload, } from '../types/escrow'; import { withRetry } from '../utils/retry'; -import { Notification, NotificationsResponse } from '../types/notification'; +import { NotificationsResponse } from '../types/notification'; import { getAccessToken, getSecureAccessToken } from './session'; const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:3000'; @@ -186,11 +186,10 @@ export const disputeApi = { mimeType: string, ): Promise<{ cid: string; url: string }> => { const formData = new FormData(); - formData.append('file', { - uri: fileUri, - name: fileName, - type: mimeType, - } as any); + /* React Native's FormData accepts { uri, name, type } but TS types don't reflect it */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileBlob = { uri: fileUri, name: fileName, type: mimeType } as any; + formData.append('file', fileBlob); const { data } = await api.post<{ cid: string; url: string }>( `/api/escrows/${escrowId}/evidence`, diff --git a/apps/mobile/services/auth.ts b/apps/mobile/services/auth.ts index 08d11216..9ae01de1 100644 --- a/apps/mobile/services/auth.ts +++ b/apps/mobile/services/auth.ts @@ -12,6 +12,7 @@ import { isSessionHydrated, subscribeToSession, } from './session'; +import { clearEscrowCache } from '../services/cache/escrowCache'; import { clearAllCache } from './cache/cacheKeys'; type RedirectTarget = { @@ -75,6 +76,7 @@ export function exitGuestMode(): void { export async function signOut(): Promise { guestMode = false; await clearSession(); + await clearEscrowCache(); notify(); } diff --git a/apps/mobile/services/cache/cacheConfig.ts b/apps/mobile/services/cache/cacheConfig.ts new file mode 100644 index 00000000..c434f70d --- /dev/null +++ b/apps/mobile/services/cache/cacheConfig.ts @@ -0,0 +1,5 @@ +export const SCHEMA_VERSION = 1; + +export const CACHE_TTL_MS = 30 * 60 * 1000; + +export const MAX_ESCROW_CACHE_ENTRIES = 50; diff --git a/apps/mobile/services/cache/dashboardCache.ts b/apps/mobile/services/cache/dashboardCache.ts index 69d9ebbc..deeb0dcf 100644 --- a/apps/mobile/services/cache/dashboardCache.ts +++ b/apps/mobile/services/cache/dashboardCache.ts @@ -2,18 +2,30 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { CACHE_KEYS } from "./cacheKeys"; +import { CACHE_TTL_MS, SCHEMA_VERSION } from "./cacheConfig"; + +export interface DashboardCacheEntry { + version: number; + updatedAt: number; + expiresAt: number; + data: unknown; + stale: boolean; +} export async function cacheDashboardData( data: unknown ) { - const payload = { + const entry: DashboardCacheEntry = { + version: SCHEMA_VERSION, updatedAt: Date.now(), + expiresAt: Date.now() + CACHE_TTL_MS, data, + stale: false, }; await AsyncStorage.setItem( CACHE_KEYS.DASHBOARD, - JSON.stringify(payload) + JSON.stringify(entry) ); } @@ -24,5 +36,33 @@ export async function getCachedDashboardData() { if (!raw) return null; - return JSON.parse(raw); -} \ No newline at end of file + try { + const entry = JSON.parse(raw) as DashboardCacheEntry; + + if (entry.version !== SCHEMA_VERSION) { + await AsyncStorage.removeItem( + CACHE_KEYS.DASHBOARD + ); + return null; + } + + const now = Date.now(); + const stale = now > entry.expiresAt; + + return { + ...entry, + stale, + }; + } catch { + await AsyncStorage.removeItem( + CACHE_KEYS.DASHBOARD + ); + return null; + } +} + +export async function clearDashboardCache() { + await AsyncStorage.removeItem( + CACHE_KEYS.DASHBOARD + ); +} diff --git a/apps/mobile/services/cache/escrowCache.ts b/apps/mobile/services/cache/escrowCache.ts index fecb6e52..8d204537 100644 --- a/apps/mobile/services/cache/escrowCache.ts +++ b/apps/mobile/services/cache/escrowCache.ts @@ -2,30 +2,112 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { CACHE_KEYS } from "./cacheKeys"; +import { + CACHE_TTL_MS, + MAX_ESCROW_CACHE_ENTRIES, + SCHEMA_VERSION, +} from "./cacheConfig"; + +export interface EscrowCacheEntry { + version: number; + updatedAt: number; + expiresAt: number; + data: unknown; + stale: boolean; +} + +const LRU_INDEX_KEY = "escrow_lru_index"; + +async function updateLruIndex(key: string) { + const raw = await AsyncStorage.getItem( + LRU_INDEX_KEY + ); + + let index: string[] = raw + ? JSON.parse(raw) + : []; + + index = index.filter((k) => k !== key); + index.unshift(key); + + const excess = index.splice( + MAX_ESCROW_CACHE_ENTRIES + ); + + if (excess.length > 0) { + await AsyncStorage.removeMany(excess); + } + + await AsyncStorage.setItem( + LRU_INDEX_KEY, + JSON.stringify(index) + ); +} export async function cacheEscrowDetail( id: string, data: unknown ) { - const payload = { + const key = CACHE_KEYS.escrowDetail(id); + + const entry: EscrowCacheEntry = { + version: SCHEMA_VERSION, updatedAt: Date.now(), + expiresAt: Date.now() + CACHE_TTL_MS, data, + stale: false, }; await AsyncStorage.setItem( - CACHE_KEYS.escrowDetail(id), - JSON.stringify(payload) + key, + JSON.stringify(entry) ); + + await updateLruIndex(key); } export async function getCachedEscrowDetail( id: string ) { + const key = CACHE_KEYS.escrowDetail(id); + + const raw = await AsyncStorage.getItem(key); + + if (!raw) return null; + + try { + const entry = JSON.parse(raw) as EscrowCacheEntry; + + if (entry.version !== SCHEMA_VERSION) { + await AsyncStorage.removeItem(key); + return null; + } + + const now = Date.now(); + const stale = now > entry.expiresAt; + + await updateLruIndex(key); + + return { + ...entry, + stale, + }; + } catch { + await AsyncStorage.removeItem(key); + return null; + } +} + +export async function clearEscrowCache() { const raw = await AsyncStorage.getItem( - CACHE_KEYS.escrowDetail(id) + LRU_INDEX_KEY ); - if (!raw) return null; + const index: string[] = raw + ? JSON.parse(raw) + : []; + + index.push(LRU_INDEX_KEY); - return JSON.parse(raw); -} \ No newline at end of file + await AsyncStorage.removeMany(index); +}