Skip to content

Commit 64f60c5

Browse files
committed
(feat): Improve the image viewer
Changelog: enhancement
1 parent d69e25c commit 64f60c5

14 files changed

Lines changed: 301 additions & 69 deletions
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { default } from '@/src/features/post/ImageViewer/ImageViewerScreen';
1+
export { default } from '@/src/common/components/ImageViewer/ImageViewerScreen';

apps/polycentric/src/features/post/ImageViewer/ImageViewer.tsx renamed to apps/polycentric/src/common/components/ImageViewer/ImageViewer.tsx

Lines changed: 9 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { Text } from '@/src/common/components/primitives';
2-
import {
3-
pickImageVariant,
4-
usePolycentric,
5-
} from '@/src/common/lib/polycentric-hooks';
2+
import { usePolycentric } from '@/src/common/lib/polycentric-hooks';
63
import { Atoms, useTheme, withHexOpacity } from '@/src/common/theme';
74
import Icon from '@/src/common/components/Icon';
8-
import { v2 } from '@polycentric/react-native';
95
import { useCallback, useEffect, useMemo, useState } from 'react';
6+
import { resolveImageSources } from './resolveImageSources';
7+
import { ImageViewerInput } from './useImageViewerStore';
108
import {
119
Image,
1210
Platform,
@@ -39,16 +37,9 @@ const MAX_SCALE = 5;
3937
/** Releasing a pinch below this scale dismisses the viewer. */
4038
const PINCH_CLOSE_SCALE = 0.8;
4139

42-
/** Pull the largest available variant for the viewer. */
43-
const VIEWER_TARGET = 2048;
44-
45-
type ViewerSource = {
46-
uri: string;
47-
aspectRatio: number;
48-
};
49-
5040
/**
51-
* Full-screen image viewer for post attachments. Tap the backdrop or
41+
* Full-screen viewer for any `ImageSet`s (post attachments, avatars,
42+
* ...). Tap the backdrop or
5243
* the close button to dismiss; pinch in or swipe up/down to close;
5344
* left/right arrows (and keyboard arrows on web) navigate between images
5445
* when there's more than one.
@@ -58,28 +49,16 @@ export function ImageViewer({
5849
initialIndex,
5950
onClose,
6051
}: {
61-
images: v2.ImageSet[];
52+
images: ImageViewerInput[];
6253
initialIndex: number;
6354
onClose: () => void;
6455
}) {
6556
const client = usePolycentric();
6657
const { theme } = useTheme();
6758
const insets = useSafeAreaInsets();
6859

69-
const sources = useMemo<ViewerSource[]>(
70-
() =>
71-
images
72-
.map((imageSet) => {
73-
const variant = pickImageVariant(imageSet, VIEWER_TARGET);
74-
const digest = variant?.blob?.digest;
75-
if (!digest) return null;
76-
const uri = client.blobUrl(digest);
77-
if (!uri) return null;
78-
const w = variant.width || 1;
79-
const h = variant.height || 1;
80-
return { uri, aspectRatio: w / h };
81-
})
82-
.filter((s): s is ViewerSource => s != null),
60+
const sources = useMemo(
61+
() => resolveImageSources(images, (digest) => client.blobUrl(digest)),
8362
[client, images],
8463
);
8564

@@ -286,7 +265,7 @@ export function ImageViewer({
286265
onPress={(e) => e.stopPropagation?.()}
287266
style={[
288267
Atoms.w_full,
289-
{ aspectRatio: current.aspectRatio, maxHeight: '100%' },
268+
{ aspectRatio: current.aspectRatio ?? 1, maxHeight: '100%' },
290269
]}
291270
>
292271
<Image

apps/polycentric/src/features/post/ImageViewer/ImageViewerScreen.tsx renamed to apps/polycentric/src/common/components/ImageViewer/ImageViewerScreen.tsx

File renamed without changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export { ImageViewer } from './ImageViewer';
2+
export { default as ImageViewerScreen } from './ImageViewerScreen';
3+
export { resolveImageSources } from './resolveImageSources';
4+
export { useImageViewer } from './useImageViewer';
5+
export { useImageViewerStore } from './useImageViewerStore';
6+
export type {
7+
ImageViewerInput,
8+
ImageViewerSource,
9+
} from './useImageViewerStore';
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { resolveImageSources, VIEWER_TARGET } from './resolveImageSources';
2+
import type { ImageViewerInput } from './useImageViewerStore';
3+
4+
// `v2` is only used as types here and in the helpers; a stub keeps the
5+
// real (native-backed) package out of the test.
6+
jest.mock('@polycentric/react-native', () => ({ v2: {} }));
7+
8+
type BlobUrl = Parameters<typeof resolveImageSources>[1];
9+
10+
// Fake ImageSet variants use string digests so blobUrl results are easy
11+
// to assert against.
12+
const variant = (width: number, height: number, digest?: string) => ({
13+
width,
14+
height,
15+
...(digest ? { blob: { digest } } : {}),
16+
});
17+
18+
const imageSet = (
19+
...variants: ReturnType<typeof variant>[]
20+
): ImageViewerInput => ({ images: variants }) as unknown as ImageViewerInput;
21+
22+
const blobUrl: BlobUrl = (digest) => `blob://${digest}`;
23+
24+
describe('resolveImageSources', () => {
25+
it('passes plain-uri sources through unchanged', () => {
26+
const plain = { uri: 'https://example.com/identicon.png' };
27+
expect(resolveImageSources([plain], blobUrl)).toEqual([plain]);
28+
});
29+
30+
it('keeps an explicit aspect ratio on plain sources', () => {
31+
const plain = { uri: 'https://example.com/banner.png', aspectRatio: 3 };
32+
expect(resolveImageSources([plain], blobUrl)).toEqual([plain]);
33+
});
34+
35+
it('resolves an ImageSet to the smallest variant at or above the target', () => {
36+
const set = imageSet(
37+
variant(512, 256, 'small'),
38+
variant(VIEWER_TARGET, 1024, 'fit'),
39+
variant(4096, 2048, 'huge'),
40+
);
41+
expect(resolveImageSources([set], blobUrl)).toEqual([
42+
{ uri: 'blob://fit', aspectRatio: 2 },
43+
]);
44+
});
45+
46+
it('falls back to the largest variant when none reach the target', () => {
47+
const set = imageSet(variant(512, 512, 'small'), variant(1024, 512, 'big'));
48+
expect(resolveImageSources([set], blobUrl)).toEqual([
49+
{ uri: 'blob://big', aspectRatio: 2 },
50+
]);
51+
});
52+
53+
it('defaults zero dimensions instead of dividing by zero', () => {
54+
const set = imageSet(variant(0, 0, 'broken'));
55+
expect(resolveImageSources([set], blobUrl)).toEqual([
56+
{ uri: 'blob://broken', aspectRatio: 1 },
57+
]);
58+
});
59+
60+
it('drops a set whose chosen variant has no digest', () => {
61+
expect(
62+
resolveImageSources([imageSet(variant(2048, 1024))], blobUrl),
63+
).toEqual([]);
64+
});
65+
66+
it('drops a set when the blob CDN is not known yet', () => {
67+
const set = imageSet(variant(2048, 1024, 'x'));
68+
expect(resolveImageSources([set], () => null)).toEqual([]);
69+
});
70+
71+
it('drops empty image sets', () => {
72+
expect(resolveImageSources([imageSet()], blobUrl)).toEqual([]);
73+
});
74+
75+
it('preserves order across mixed inputs and skips unresolvable ones', () => {
76+
const sources = resolveImageSources(
77+
[
78+
{ uri: 'plain://first' },
79+
imageSet(variant(2048, 2048, 'second')),
80+
imageSet(), // unresolvable
81+
{ uri: 'plain://third', aspectRatio: 0.5 },
82+
],
83+
blobUrl,
84+
);
85+
expect(sources.map((s) => s.uri)).toEqual([
86+
'plain://first',
87+
'blob://second',
88+
'plain://third',
89+
]);
90+
});
91+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { pickImageVariant } from '@/src/common/lib/polycentric-hooks/helpers';
2+
import { v2 } from '@polycentric/react-native';
3+
import { ImageViewerInput, ImageViewerSource } from './useImageViewerStore';
4+
5+
/** Pull the largest available variant for the viewer. */
6+
export const VIEWER_TARGET = 2048;
7+
8+
type BlobDigest = NonNullable<NonNullable<v2.Image['blob']>['digest']>;
9+
10+
/**
11+
* Resolve the image source from an array of mixed inputs
12+
*/
13+
export function resolveImageSources(
14+
images: ImageViewerInput[],
15+
blobUrl: (digest: BlobDigest) => string | null,
16+
): ImageViewerSource[] {
17+
return images
18+
.map((image) => {
19+
if ('uri' in image) {
20+
return image;
21+
}
22+
const variant = pickImageVariant(image, VIEWER_TARGET);
23+
const digest = variant?.blob?.digest;
24+
if (!digest) return null;
25+
const uri = blobUrl(digest);
26+
if (!uri) return null;
27+
const w = variant.width || 1;
28+
const h = variant.height || 1;
29+
return { uri, aspectRatio: w / h };
30+
})
31+
.filter((s): s is ImageViewerSource => s != null);
32+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as React from 'react';
2+
import { act } from 'react';
3+
import TestRenderer from 'react-test-renderer';
4+
import { router } from 'expo-router';
5+
import { useImageViewer } from './useImageViewer';
6+
import { useImageViewerStore } from './useImageViewerStore';
7+
8+
jest.mock('@polycentric/react-native', () => ({ v2: {} }));
9+
10+
jest.mock('expo-router', () => ({
11+
router: { push: jest.fn() },
12+
}));
13+
14+
/** Render the hook in a throwaway component and hand back its callback. */
15+
function renderUseImageViewer() {
16+
let open!: ReturnType<typeof useImageViewer>;
17+
function Harness() {
18+
open = useImageViewer();
19+
return null;
20+
}
21+
act(() => {
22+
TestRenderer.create(React.createElement(Harness));
23+
});
24+
return open;
25+
}
26+
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
useImageViewerStore.setState({ images: [], index: 0 });
30+
});
31+
32+
describe('useImageViewer', () => {
33+
it('stores the images and pushes the image-viewer route', () => {
34+
const open = renderUseImageViewer();
35+
const images = [{ uri: 'a' }, { uri: 'b' }];
36+
37+
act(() => open(images, 1));
38+
39+
expect(useImageViewerStore.getState().images).toBe(images);
40+
expect(useImageViewerStore.getState().index).toBe(1);
41+
expect(router.push).toHaveBeenCalledWith('/image-viewer');
42+
});
43+
44+
it('defaults the starting index to 0', () => {
45+
const open = renderUseImageViewer();
46+
47+
act(() => open([{ uri: 'a' }]));
48+
49+
expect(useImageViewerStore.getState().index).toBe(0);
50+
});
51+
52+
it('does nothing for an empty image list', () => {
53+
const open = renderUseImageViewer();
54+
55+
act(() => open([]));
56+
57+
expect(router.push).not.toHaveBeenCalled();
58+
expect(useImageViewerStore.getState().images).toEqual([]);
59+
});
60+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { router } from 'expo-router';
2+
import { useCallback } from 'react';
3+
import { useImageViewerStore, ImageViewerInput } from './useImageViewerStore';
4+
5+
/**
6+
* Opens the full-screen image viewer from anywhere in the app: stashes
7+
* the images in {@link useImageViewerStore} and pushes the
8+
* `image-viewer` route that reads them back out.
9+
*/
10+
export function useImageViewer() {
11+
const show = useImageViewerStore((s) => s.show);
12+
return useCallback(
13+
(images: ImageViewerInput[], index = 0) => {
14+
if (images.length === 0) return;
15+
show(images, index);
16+
router.push('/image-viewer');
17+
},
18+
[show],
19+
);
20+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useImageViewerStore } from './useImageViewerStore';
2+
3+
jest.mock('@polycentric/react-native', () => ({ v2: {} }));
4+
5+
// Plain zustand store: drive it via getState()/setState() without React.
6+
const get = () => useImageViewerStore.getState();
7+
8+
beforeEach(() => {
9+
useImageViewerStore.setState({ images: [], index: 0 });
10+
});
11+
12+
describe('useImageViewerStore', () => {
13+
it('starts empty at index 0', () => {
14+
expect(get().images).toEqual([]);
15+
expect(get().index).toBe(0);
16+
});
17+
18+
it('show() sets the images and starting index', () => {
19+
const images = [{ uri: 'a' }, { uri: 'b' }];
20+
get().show(images, 1);
21+
expect(get().images).toBe(images);
22+
expect(get().index).toBe(1);
23+
});
24+
25+
it('show() replaces a previous set entirely', () => {
26+
get().show([{ uri: 'a' }, { uri: 'b' }], 1);
27+
get().show([{ uri: 'c' }], 0);
28+
expect(get().images).toEqual([{ uri: 'c' }]);
29+
expect(get().index).toBe(0);
30+
});
31+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { v2 } from '@polycentric/react-native';
2+
import { create } from 'zustand';
3+
4+
/** A directly renderable image; aspectRatio defaults to square. */
5+
export type ImageViewerSource = { uri: string; aspectRatio?: number };
6+
7+
/**
8+
* Anything the viewer can display: a Polycentric `ImageSet` (resolved
9+
* to the largest blob variant) or a plain URI (e.g. an identicon).
10+
*/
11+
export type ImageViewerInput = v2.ImageSet | ImageViewerSource;
12+
13+
type ImageViewerState = {
14+
images: ImageViewerInput[];
15+
index: number;
16+
};
17+
18+
type ImageViewerActions = {
19+
/** Set the images + starting index for the image-viewer route to read. */
20+
show: (images: ImageViewerInput[], index: number) => void;
21+
};
22+
23+
export const useImageViewerStore = create<
24+
ImageViewerState & ImageViewerActions
25+
>((set) => ({
26+
images: [],
27+
index: 0,
28+
show: (images, index) => set({ images, index }),
29+
}));

0 commit comments

Comments
 (0)