Skip to content

Commit 66c50ea

Browse files
committed
Fix up multi image view
1 parent d0e0b9d commit 66c50ea

6 files changed

Lines changed: 505 additions & 152 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const AVATAR_SIZE_MAP: Record<AvatarSizePreset, number> = {
1717
sm: 32,
1818
md: 40,
1919
lg: 56,
20-
xl: 80,
20+
xl: 112,
2121
massive: 170,
2222
};
2323

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { Text } from '@/src/common/components/primitives';
2+
import {
3+
pickImageVariant,
4+
usePolycentric,
5+
} from '@/src/common/lib/polycentric-hooks';
6+
import { Atoms, useTheme, withHexOpacity } from '@/src/common/theme';
7+
import { Ionicons } from '@expo/vector-icons';
8+
import { v2 } from '@polycentric/react-native';
9+
import { useCallback, useEffect, useMemo, useState } from 'react';
10+
import { Image, Modal, Platform, Pressable, View } from 'react-native';
11+
12+
/** Pull the largest available variant for the viewer. */
13+
const VIEWER_TARGET = 2048;
14+
15+
type ViewerSource = {
16+
uri: string;
17+
aspectRatio: number;
18+
};
19+
20+
/**
21+
* Full-screen image viewer for post attachments. Tap the backdrop or
22+
* the close button to dismiss; left/right arrows (and keyboard arrows
23+
* on web) navigate between images when there's more than one.
24+
*/
25+
export function ImageViewer({
26+
images,
27+
initialIndex,
28+
onClose,
29+
}: {
30+
images: v2.ImageSet[];
31+
initialIndex: number;
32+
onClose: () => void;
33+
}) {
34+
const client = usePolycentric();
35+
const { theme } = useTheme();
36+
37+
const sources = useMemo<ViewerSource[]>(
38+
() =>
39+
images
40+
.map((imageSet) => {
41+
const variant = pickImageVariant(imageSet, VIEWER_TARGET);
42+
const digest = variant?.blob?.digest;
43+
if (!digest) return null;
44+
const uri = client.blobUrl(digest);
45+
if (!uri) return null;
46+
const w = variant.width || 1;
47+
const h = variant.height || 1;
48+
return { uri, aspectRatio: w / h };
49+
})
50+
.filter((s): s is ViewerSource => s != null),
51+
[client, images],
52+
);
53+
54+
const [index, setIndex] = useState(initialIndex);
55+
useEffect(() => {
56+
setIndex(initialIndex);
57+
}, [initialIndex]);
58+
59+
const goPrev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
60+
const goNext = useCallback(
61+
() => setIndex((i) => Math.min(sources.length - 1, i + 1)),
62+
[sources.length],
63+
);
64+
65+
// Web: Esc closes, arrow keys navigate.
66+
useEffect(() => {
67+
if (Platform.OS !== 'web') return;
68+
const handler = (e: KeyboardEvent) => {
69+
if (e.key === 'Escape') onClose();
70+
else if (e.key === 'ArrowLeft') goPrev();
71+
else if (e.key === 'ArrowRight') goNext();
72+
};
73+
window.addEventListener('keydown', handler);
74+
return () => window.removeEventListener('keydown', handler);
75+
}, [onClose, goPrev, goNext]);
76+
77+
if (sources.length === 0) return null;
78+
const safeIndex = Math.min(index, sources.length - 1);
79+
const current = sources[safeIndex];
80+
const hasPrev = safeIndex > 0;
81+
const hasNext = safeIndex < sources.length - 1;
82+
83+
const chipBg = withHexOpacity(theme.palette.black, 'b0');
84+
85+
return (
86+
<Modal visible transparent onRequestClose={onClose} animationType="fade">
87+
<Pressable
88+
onPress={onClose}
89+
style={[
90+
Atoms.flex_1,
91+
Atoms.items_center,
92+
Atoms.justify_center,
93+
{ backgroundColor: 'rgba(0,0,0,0.92)' },
94+
]}
95+
>
96+
{/* Swallow taps on the image so they don't dismiss. */}
97+
<Pressable
98+
onPress={(e) => e.stopPropagation?.()}
99+
style={[
100+
Atoms.items_center,
101+
Atoms.justify_center,
102+
{ width: '92%', height: '88%' },
103+
]}
104+
>
105+
<Image
106+
source={{ uri: current.uri }}
107+
resizeMode="contain"
108+
style={[
109+
Atoms.w_full,
110+
Atoms.h_full,
111+
{ aspectRatio: current.aspectRatio },
112+
]}
113+
/>
114+
</Pressable>
115+
116+
<Pressable
117+
onPress={(e) => {
118+
e.stopPropagation?.();
119+
onClose();
120+
}}
121+
accessibilityLabel="Close image viewer"
122+
hitSlop={12}
123+
style={[
124+
Atoms.absolute,
125+
Atoms.items_center,
126+
Atoms.justify_center,
127+
Atoms.rounded_full,
128+
{
129+
top: 16,
130+
right: 16,
131+
width: 40,
132+
height: 40,
133+
backgroundColor: chipBg,
134+
},
135+
]}
136+
>
137+
<Ionicons name="close" size={24} color={theme.palette.white} />
138+
</Pressable>
139+
140+
{hasPrev && <NavArrow side="left" onPress={goPrev} bg={chipBg} />}
141+
{hasNext && <NavArrow side="right" onPress={goNext} bg={chipBg} />}
142+
143+
{sources.length > 1 && (
144+
<View
145+
pointerEvents="none"
146+
style={[
147+
Atoms.absolute,
148+
Atoms.items_center,
149+
{ top: 20, left: 0, right: 0 },
150+
]}
151+
>
152+
<View
153+
style={[
154+
Atoms.px_sm,
155+
Atoms.py_xs,
156+
Atoms.rounded_lg,
157+
{ backgroundColor: chipBg },
158+
]}
159+
>
160+
<Text variant="small" style={{ color: theme.palette.white }}>
161+
{safeIndex + 1} / {sources.length}
162+
</Text>
163+
</View>
164+
</View>
165+
)}
166+
</Pressable>
167+
</Modal>
168+
);
169+
}
170+
171+
function NavArrow({
172+
side,
173+
onPress,
174+
bg,
175+
}: {
176+
side: 'left' | 'right';
177+
onPress: () => void;
178+
bg: string;
179+
}) {
180+
const { theme } = useTheme();
181+
return (
182+
<Pressable
183+
onPress={(e) => {
184+
e.stopPropagation?.();
185+
onPress();
186+
}}
187+
hitSlop={12}
188+
accessibilityLabel={side === 'left' ? 'Previous image' : 'Next image'}
189+
style={[
190+
Atoms.absolute,
191+
Atoms.items_center,
192+
Atoms.justify_center,
193+
Atoms.rounded_full,
194+
{
195+
top: '50%',
196+
width: 44,
197+
height: 44,
198+
transform: [{ translateY: -22 }],
199+
backgroundColor: bg,
200+
},
201+
side === 'left' ? { left: 16 } : { right: 16 },
202+
]}
203+
>
204+
<Ionicons
205+
name={side === 'left' ? 'chevron-back' : 'chevron-forward'}
206+
size={28}
207+
color={theme.palette.white}
208+
/>
209+
</Pressable>
210+
);
211+
}

0 commit comments

Comments
 (0)