|
| 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