diff --git a/app/(tabs)/beneficiaries/[id]/index.tsx b/app/(tabs)/beneficiaries/[id]/index.tsx index b83daee..dea4e44 100644 --- a/app/(tabs)/beneficiaries/[id]/index.tsx +++ b/app/(tabs)/beneficiaries/[id]/index.tsx @@ -43,6 +43,7 @@ import { } from '@/services/BeneficiaryDetailController'; import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu'; import { ImageLightbox } from '@/components/ImageLightbox'; +import { bustImageCache } from '@/utils/imageUtils'; // WebView Dashboard URL - opens specific deployment directly const getDashboardUrl = (deploymentId?: number) => { @@ -298,7 +299,8 @@ export default function BeneficiaryDetailScreen() { setIsEditModalVisible(false); toast.success('Saved', isCustodian ? 'Profile updated successfully' : 'Nickname saved'); - loadBeneficiary(false); + // Reload to get updated data with fresh avatar URL (cache-busting timestamp will be applied) + await loadBeneficiary(false); } catch (err) { toast.error('Error', 'Failed to save changes.'); } finally { @@ -389,7 +391,7 @@ export default function BeneficiaryDetailScreen() { disabled={!beneficiary.avatar || beneficiary.avatar.trim() === '' || beneficiary.avatar.includes('placeholder')} > {beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder') ? ( - + ) : ( @@ -526,7 +528,7 @@ export default function BeneficiaryDetailScreen() { disabled={isSavingEdit} > {editForm.avatar ? ( - + ) : ( @@ -625,7 +627,7 @@ export default function BeneficiaryDetailScreen() { {/* Avatar Lightbox */} setLightboxVisible(false)} /> diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 956aed2..40125d9 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -27,6 +27,7 @@ import { } from '@/constants/theme'; import type { Beneficiary } from '@/types'; import { isSubscriptionActive } from '@/services/subscription'; +import { bustImageCache } from '@/utils/imageUtils'; // Beneficiary card with equipment status support interface BeneficiaryCardProps { @@ -115,7 +116,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr {/* Avatar */} {hasValidAvatar ? ( - + ) : ( diff --git a/utils/__tests__/imageUtils.test.ts b/utils/__tests__/imageUtils.test.ts new file mode 100644 index 0000000..11a0eac --- /dev/null +++ b/utils/__tests__/imageUtils.test.ts @@ -0,0 +1,65 @@ +import { bustImageCache } from '../imageUtils'; + +describe('bustImageCache', () => { + it('should add cache-busting parameter to HTTP URLs', () => { + const url = 'https://example.com/avatar.jpg'; + const result = bustImageCache(url, 1234567890); + expect(result).toBe('https://example.com/avatar.jpg?t=1234567890'); + }); + + it('should add cache-busting parameter to URLs with existing query params', () => { + const url = 'https://example.com/avatar.jpg?size=large'; + const result = bustImageCache(url, 1234567890); + expect(result).toBe('https://example.com/avatar.jpg?size=large&t=1234567890'); + }); + + it('should not add cache-busting to file:// URIs', () => { + const uri = 'file:///path/to/local/image.jpg'; + const result = bustImageCache(uri); + expect(result).toBe(uri); + }); + + it('should not add cache-busting to data URIs', () => { + const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + const result = bustImageCache(dataUri); + expect(result).toBe(dataUri); + }); + + it('should not add cache-busting to placeholder images', () => { + const placeholderUrl = 'https://example.com/placeholder.png'; + const result = bustImageCache(placeholderUrl); + expect(result).toBe(placeholderUrl); + }); + + it('should return null for null input', () => { + const result = bustImageCache(null); + expect(result).toBeNull(); + }); + + it('should return null for undefined input', () => { + const result = bustImageCache(undefined); + expect(result).toBeNull(); + }); + + it('should return null for empty string', () => { + const result = bustImageCache(''); + expect(result).toBeNull(); + }); + + it('should use current timestamp when not provided', () => { + const url = 'https://example.com/avatar.jpg'; + const beforeTimestamp = Date.now(); + const result = bustImageCache(url); + const afterTimestamp = Date.now(); + + // Extract timestamp from result + const match = result?.match(/t=(\d+)/); + expect(match).toBeTruthy(); + + if (match) { + const timestamp = parseInt(match[1], 10); + expect(timestamp).toBeGreaterThanOrEqual(beforeTimestamp); + expect(timestamp).toBeLessThanOrEqual(afterTimestamp); + } + }); +}); diff --git a/utils/imageUtils.ts b/utils/imageUtils.ts index e8b4b63..b801866 100644 --- a/utils/imageUtils.ts +++ b/utils/imageUtils.ts @@ -51,3 +51,36 @@ export async function optimizeImage( return uri; } } + +/** + * Add cache-busting query parameter to image URL + * Prevents browsers and React Native Image from caching old versions + * + * @param uri - Image URI (can be http, https, or file://) + * @param timestamp - Optional timestamp (defaults to current time) + * @returns URI with cache-busting parameter + */ +export function bustImageCache(uri: string | null | undefined, timestamp?: number): string | null { + if (!uri || uri.trim() === '') { + return null; + } + + // Don't add cache buster to local file URIs (file://) + if (uri.startsWith('file://')) { + return uri; + } + + // Don't add cache buster to data URIs + if (uri.startsWith('data:')) { + return uri; + } + + // Don't add cache buster to placeholder images + if (uri.includes('placeholder')) { + return uri; + } + + const cacheBuster = timestamp || Date.now(); + const separator = uri.includes('?') ? '&' : '?'; + return `${uri}${separator}t=${cacheBuster}`; +}