Skip to content

Commit 9c708cc

Browse files
committed
[#114] Images fallback to other servers if they fail to load
changelog: enhancement
1 parent e856cb6 commit 9c708cc

14 files changed

Lines changed: 287 additions & 140 deletions

File tree

apps/polycentric/src/common/components/Avatar/ProfileAvatar.tsx

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,17 @@ import {
55
pickImageVariant,
66
usePolycentric,
77
} from '../../lib/polycentric-hooks';
8+
import { useFallbackUri } from '@/src/common/components/Image';
89
import { useProfile } from '@/src/features/profile/hooks/useProfile';
910

1011
type ProfileAvatarProps = {
1112
identityKey: string;
1213
} & Omit<ComponentProps<typeof Avatar>, 'source'>;
1314

1415
/**
15-
* Avatar bound to a Polycentric identity. Picks the best-fitting
16-
* variant from the profile's `avatar` ImageSet and serves it from the
17-
* server's reported blob CDN (see `ServerService.GetInfo`). Falls back
18-
* to a Dicebear identicon when no avatar is set or the CDN hasn't
19-
* been resolved yet.
16+
* Avatar bound to a Polycentric identity. Picks the best-fitting variant
17+
* from the profile's `avatar` ImageSet, trying each server in turn and
18+
* falling back to a Dicebear identicon if none serve it.
2019
*/
2120
export function ProfileAvatar({
2221
identityKey,
@@ -27,14 +26,23 @@ export function ProfileAvatar({
2726
const client = usePolycentric();
2827
const pixelSize = resolveAvatarSize(size);
2928

30-
const uri = useMemo(() => {
29+
const candidates = useMemo(() => {
3130
const variant = pickImageVariant(profile.avatar, pixelSize);
32-
if (variant?.blob?.digest) {
33-
const url = client.blobUrl(variant.blob.digest);
34-
if (url) return url;
35-
}
36-
return identiconUrl(identityKey, pixelSize);
31+
const blobUris = variant?.blob?.digest
32+
? client.blobUrls(variant.blob.digest)
33+
: [];
34+
return [...blobUris, identiconUrl(identityKey, pixelSize)];
3735
}, [profile.avatar, client, identityKey, pixelSize]);
3836

39-
return <Avatar {...rest} size={size} source={{ uri }} />;
37+
const { uri, onError } = useFallbackUri(candidates);
38+
39+
return (
40+
<Avatar
41+
{...rest}
42+
size={size}
43+
source={{ uri }}
44+
recyclingKey={candidates[0]}
45+
onError={onError}
46+
/>
47+
);
4048
}

apps/polycentric/src/common/components/Avatar/ProfileEditAvatar.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ type ProfileEditAvatarProps = {
1414

1515
/**
1616
* Edit-mode counterpart to {@link ProfileAvatar}: seeds the image picker with
17-
* the identity's current avatar (resolved the same way — best-fitting variant
18-
* from the profile's `avatar` ImageSet via the blob CDN, falling back to a
19-
* Dicebear identicon) before the user chooses a replacement.
17+
* the identity's current avatar (best-fitting variant from the profile's
18+
* `avatar` ImageSet, falling back to a Dicebear identicon) before the user
19+
* chooses a replacement.
2020
*/
2121
export function ProfileEditAvatar({
2222
identityKey,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { act, render } from '@testing-library/react-native';
2+
import { Image } from './Image';
3+
4+
// expo-image normalizes `source={{ uri }}` into an array of sources, so read
5+
// the first entry's uri to learn which candidate is currently showing.
6+
const shownUri = (node: { props: { source?: { uri?: string }[] } }) =>
7+
node.props.source?.[0]?.uri;
8+
9+
// Simulate a failed load by invoking the rendered image's onError, the same
10+
// way expo-image calls it on the native side.
11+
const fail = (node: { props: { onError?: (e: unknown) => void } }) =>
12+
act(() => node.props.onError?.({ nativeEvent: { error: 'boom' } }));
13+
14+
describe('Image', () => {
15+
it('shows the first candidate', async () => {
16+
const { getByTestId } = await render(
17+
<Image testID="img" uris={['a://1', 'b://1']} />,
18+
);
19+
expect(shownUri(getByTestId('img'))).toBe('a://1');
20+
});
21+
22+
it('falls through to the next candidate when one fails to load', async () => {
23+
const { getByTestId } = await render(
24+
<Image testID="img" uris={['a://1', 'b://1']} />,
25+
);
26+
27+
await fail(getByTestId('img'));
28+
expect(shownUri(getByTestId('img'))).toBe('b://1');
29+
});
30+
31+
it('stops at the last candidate once all have failed', async () => {
32+
const { getByTestId } = await render(
33+
<Image testID="img" uris={['a://1', 'b://1']} />,
34+
);
35+
36+
await fail(getByTestId('img'));
37+
await fail(getByTestId('img'));
38+
await fail(getByTestId('img'));
39+
expect(shownUri(getByTestId('img'))).toBe('b://1');
40+
});
41+
42+
it('keeps a stable recyclingKey across fallbacks', async () => {
43+
const { getByTestId } = await render(
44+
<Image testID="img" uris={['a://1', 'b://1']} />,
45+
);
46+
const before = getByTestId('img').props.recyclingKey;
47+
48+
await fail(getByTestId('img'));
49+
expect(getByTestId('img').props.recyclingKey).toBe(before);
50+
expect(before).toBe('a://1');
51+
});
52+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Image as ExpoImage, type ImageProps } from 'expo-image';
2+
import { useFallbackUri } from './useFallbackUri';
3+
4+
export type { ImageProps };
5+
6+
type Props = Omit<ImageProps, 'source' | 'onError'> & {
7+
/** URLs tried in order; falls through to the next on load failure. */
8+
uris: string[];
9+
};
10+
11+
/** expo-image that retries the next URL when one fails to load. */
12+
export function Image({ uris, recyclingKey, ...rest }: Props) {
13+
const { uri, onError } = useFallbackUri(uris);
14+
return (
15+
<ExpoImage
16+
{...rest}
17+
source={uri ? { uri } : undefined}
18+
recyclingKey={recyclingKey ?? uris[0]}
19+
onError={onError}
20+
/>
21+
);
22+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Image, type ImageProps } from './Image';
2+
export { useFallbackUri } from './useFallbackUri';
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { act } from 'react';
2+
import TestRenderer from 'react-test-renderer';
3+
import { useFallbackUri } from './useFallbackUri';
4+
5+
type Api = ReturnType<typeof useFallbackUri>;
6+
7+
/** Render the hook in a probe; `result.current` tracks the latest value. */
8+
function renderHook(initial: string[]) {
9+
const result: { current: Api } = { current: null as never };
10+
function Probe({ uris }: { uris: string[] }) {
11+
result.current = useFallbackUri(uris);
12+
return null;
13+
}
14+
let renderer!: TestRenderer.ReactTestRenderer;
15+
act(() => {
16+
renderer = TestRenderer.create(<Probe uris={initial} />);
17+
});
18+
const rerender = (uris: string[]) =>
19+
act(() => {
20+
renderer.update(<Probe uris={uris} />);
21+
});
22+
return { result, rerender };
23+
}
24+
25+
describe('useFallbackUri', () => {
26+
it('starts on the first candidate', () => {
27+
const { result } = renderHook(['a', 'b', 'c']);
28+
expect(result.current.uri).toBe('a');
29+
});
30+
31+
it('advances to the next candidate on error', () => {
32+
const { result } = renderHook(['a', 'b', 'c']);
33+
34+
act(() => result.current.onError());
35+
expect(result.current.uri).toBe('b');
36+
37+
act(() => result.current.onError());
38+
expect(result.current.uri).toBe('c');
39+
});
40+
41+
it('stays on the last candidate once every one has failed', () => {
42+
const { result } = renderHook(['a', 'b']);
43+
44+
act(() => result.current.onError());
45+
act(() => result.current.onError());
46+
act(() => result.current.onError());
47+
expect(result.current.uri).toBe('b');
48+
});
49+
50+
it('resets to the first candidate when the list changes', () => {
51+
const { result, rerender } = renderHook(['a', 'b']);
52+
53+
act(() => result.current.onError());
54+
expect(result.current.uri).toBe('b');
55+
56+
rerender(['x', 'y']);
57+
expect(result.current.uri).toBe('x');
58+
});
59+
60+
it('keeps its position when the same list is passed again', () => {
61+
const { result, rerender } = renderHook(['a', 'b']);
62+
63+
act(() => result.current.onError());
64+
rerender(['a', 'b']);
65+
expect(result.current.uri).toBe('b');
66+
});
67+
68+
it('yields undefined for an empty list', () => {
69+
const { result } = renderHook([]);
70+
expect(result.current.uri).toBeUndefined();
71+
// Erroring on nothing is a no-op, not a crash.
72+
act(() => result.current.onError());
73+
expect(result.current.uri).toBeUndefined();
74+
});
75+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
3+
/**
4+
* Walk a list of candidate URLs, advancing to the next when one fails to
5+
* load. Lets a blob be retried across servers. Resets when the list changes.
6+
* Returns the current `uri` and an `onError` to wire into the image.
7+
*/
8+
export function useFallbackUri(candidates: string[]): {
9+
uri: string | undefined;
10+
onError: () => void;
11+
} {
12+
const key = candidates.join('\n');
13+
const [index, setIndex] = useState(0);
14+
15+
useEffect(() => setIndex(0), [key]);
16+
17+
const onError = useCallback(() => {
18+
setIndex((i) => (i + 1 < candidates.length ? i + 1 : i));
19+
}, [candidates.length]);
20+
21+
return { uri: candidates[index], onError };
22+
}

apps/polycentric/src/common/components/ImageViewer/ImageViewer.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Icon from '@/src/common/components/Icon';
55
import { useCallback, useEffect, useMemo, useState } from 'react';
66
import { resolveImageSources } from './resolveImageSources';
77
import { ImageViewerInput } from './useImageViewerStore';
8-
import { Image } from 'expo-image';
8+
import { Image } from '@/src/common/components/Image';
99
import {
1010
Platform,
1111
Pressable,
@@ -58,7 +58,7 @@ export function ImageViewer({
5858
const insets = useSafeAreaInsets();
5959

6060
const sources = useMemo(
61-
() => resolveImageSources(images, (digest) => client.blobUrl(digest)),
61+
() => resolveImageSources(images, (digest) => client.blobUrls(digest)),
6262
[client, images],
6363
);
6464

@@ -269,8 +269,7 @@ export function ImageViewer({
269269
]}
270270
>
271271
<Image
272-
source={{ uri: current.uri }}
273-
recyclingKey={current.uri}
272+
uris={current.uris}
274273
contentFit="contain"
275274
style={[Atoms.w_full, Atoms.h_full]}
276275
/>

apps/polycentric/src/common/components/ImageViewer/resolveImageSources.test.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import type { ImageViewerInput } from './useImageViewerStore';
55
// real (native-backed) package out of the test.
66
jest.mock('@polycentric/react-native', () => ({ v2: {} }));
77

8-
type BlobUrl = Parameters<typeof resolveImageSources>[1];
8+
type BlobUrls = Parameters<typeof resolveImageSources>[1];
99

10-
// Fake ImageSet variants use string digests so blobUrl results are easy
10+
// Fake ImageSet variants use string digests so blobUrls results are easy
1111
// to assert against.
1212
const variant = (width: number, height: number, digest?: string) => ({
1313
width,
@@ -19,17 +19,21 @@ const imageSet = (
1919
...variants: ReturnType<typeof variant>[]
2020
): ImageViewerInput => ({ images: variants }) as unknown as ImageViewerInput;
2121

22-
const blobUrl: BlobUrl = (digest) => `blob://${digest}`;
22+
const blobUrls: BlobUrls = (digest) => [`blob://${digest}`];
2323

2424
describe('resolveImageSources', () => {
25-
it('passes plain-uri sources through unchanged', () => {
25+
it('wraps plain-uri sources in a single-candidate list', () => {
2626
const plain = { uri: 'https://example.com/identicon.png' };
27-
expect(resolveImageSources([plain], blobUrl)).toEqual([plain]);
27+
expect(resolveImageSources([plain], blobUrls)).toEqual([
28+
{ uris: ['https://example.com/identicon.png'], aspectRatio: undefined },
29+
]);
2830
});
2931

3032
it('keeps an explicit aspect ratio on plain sources', () => {
3133
const plain = { uri: 'https://example.com/banner.png', aspectRatio: 3 };
32-
expect(resolveImageSources([plain], blobUrl)).toEqual([plain]);
34+
expect(resolveImageSources([plain], blobUrls)).toEqual([
35+
{ uris: ['https://example.com/banner.png'], aspectRatio: 3 },
36+
]);
3337
});
3438

3539
it('resolves an ImageSet to the smallest variant at or above the target', () => {
@@ -38,38 +42,46 @@ describe('resolveImageSources', () => {
3842
variant(VIEWER_TARGET, 1024, 'fit'),
3943
variant(4096, 2048, 'huge'),
4044
);
41-
expect(resolveImageSources([set], blobUrl)).toEqual([
42-
{ uri: 'blob://fit', aspectRatio: 2 },
45+
expect(resolveImageSources([set], blobUrls)).toEqual([
46+
{ uris: ['blob://fit'], aspectRatio: 2 },
4347
]);
4448
});
4549

4650
it('falls back to the largest variant when none reach the target', () => {
4751
const set = imageSet(variant(512, 512, 'small'), variant(1024, 512, 'big'));
48-
expect(resolveImageSources([set], blobUrl)).toEqual([
49-
{ uri: 'blob://big', aspectRatio: 2 },
52+
expect(resolveImageSources([set], blobUrls)).toEqual([
53+
{ uris: ['blob://big'], aspectRatio: 2 },
54+
]);
55+
});
56+
57+
it('keeps one candidate uri per server', () => {
58+
const set = imageSet(variant(2048, 1024, 'x'));
59+
const multi: BlobUrls = (digest) => [`a://${digest}`, `b://${digest}`];
60+
expect(resolveImageSources([set], multi)).toEqual([
61+
{ uris: ['a://x', 'b://x'], aspectRatio: 2 },
5062
]);
5163
});
5264

5365
it('defaults zero dimensions instead of dividing by zero', () => {
5466
const set = imageSet(variant(0, 0, 'broken'));
55-
expect(resolveImageSources([set], blobUrl)).toEqual([
56-
{ uri: 'blob://broken', aspectRatio: 1 },
67+
expect(resolveImageSources([set], blobUrls)).toEqual([
68+
{ uris: ['blob://broken'], aspectRatio: 1 },
5769
]);
5870
});
5971

6072
it('drops a set whose chosen variant has no digest', () => {
6173
expect(
62-
resolveImageSources([imageSet(variant(2048, 1024))], blobUrl),
74+
resolveImageSources([imageSet(variant(2048, 1024))], blobUrls),
6375
).toEqual([]);
6476
});
6577

66-
it('drops a set when the blob CDN is not known yet', () => {
78+
it('drops a set when no server can serve it', () => {
6779
const set = imageSet(variant(2048, 1024, 'x'));
68-
expect(resolveImageSources([set], () => null)).toEqual([]);
80+
expect(resolveImageSources([set], () => [])).toEqual([]);
6981
});
7082

7183
it('drops empty image sets', () => {
72-
expect(resolveImageSources([imageSet()], blobUrl)).toEqual([]);
84+
expect(resolveImageSources([imageSet()], blobUrls)).toEqual([]);
7385
});
7486

7587
it('preserves order across mixed inputs and skips unresolvable ones', () => {
@@ -80,9 +92,9 @@ describe('resolveImageSources', () => {
8092
imageSet(), // unresolvable
8193
{ uri: 'plain://third', aspectRatio: 0.5 },
8294
],
83-
blobUrl,
95+
blobUrls,
8496
);
85-
expect(sources.map((s) => s.uri)).toEqual([
97+
expect(sources.map((s) => s.uris[0])).toEqual([
8698
'plain://first',
8799
'blob://second',
88100
'plain://third',

0 commit comments

Comments
 (0)