From 740415cce7badcbad9480d6e54dcfe7cf52ec3da Mon Sep 17 00:00:00 2001 From: wbaxterh Date: Sat, 11 Jul 2026 16:57:21 -0700 Subject: [PATCH 1/5] feat(spots+feed): tag a spot in posts, any-user photo contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feed spot-tagging: - Compose (media/upload.tsx): optional "Tag a spot" picker (debounced search) → sends spotId on the post. Posting without a spot is unchanged. - The feed list and single-post views show a tappable spot chip that opens the spot. (Backend now enriches post.spot on lists too.) Spot photo contributions (Google-Maps model): - Any logged-in user can add photos to any spot from the detail screen (gallery multi-select or camera) → uploads then refreshes the gallery. - Per-photo actions on user photos: Report (any user except the uploader) and Delete (uploader/admin). Google photos aren't reportable/deletable. - Photos are public immediately; the backend auto-hides one after 3 distinct reports (App-Store-compliant UGC moderation). API (src/lib/api): reportSpotPhoto / deleteSpotPhoto (photo key is encodeURIComponent'd — the S3 key contains a "/", verified against prod route matching); spotId on CreatePostData + spot on FeedPost. Fixes from code review: encoded photo-key (report/delete were 404-ing); gallery no longer double-renders the header photo; can't report your own photo; refresh after report so an auto-hidden photo disappears. Co-Authored-By: Claude Fable 5 --- app/(tabs)/media/index.tsx | 33 ++++ app/(tabs)/media/post/[postId].tsx | 48 +++++ app/(tabs)/media/upload.tsx | 269 ++++++++++++++++++++++++++- app/(tabs)/spots/[spotId].tsx | 281 +++++++++++++++++++++++++++-- src/lib/api/feed.ts | 11 ++ src/lib/api/spots.ts | 31 ++++ 6 files changed, 654 insertions(+), 19 deletions(-) diff --git a/app/(tabs)/media/index.tsx b/app/(tabs)/media/index.tsx index 4a63fbb..b0b2909 100644 --- a/app/(tabs)/media/index.tsx +++ b/app/(tabs)/media/index.tsx @@ -893,6 +893,19 @@ const FeedVideoItem = memo(function FeedVideoItem({ )} + {/* Spot chip */} + {post.spot && ( + router.push(`/(tabs)/spots/${post.spot?._id}`)} + > + + + {post.spot.name} + + + )} + {/* Tags */} {post.sportTypes && post.sportTypes.length > 0 && ( @@ -1379,4 +1392,24 @@ const styles = StyleSheet.create({ color: 'rgba(255,255,255,0.7)', fontSize: 12, }, + spotChip: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: 4, + maxWidth: '100%', + backgroundColor: 'rgba(0,0,0,0.4)', + borderWidth: 1, + borderColor: `${YELLOW}80`, + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 16, + marginBottom: 8, + }, + spotChipText: { + color: '#fff', + fontSize: 13, + fontWeight: '600', + flexShrink: 1, + }, }); diff --git a/app/(tabs)/media/post/[postId].tsx b/app/(tabs)/media/post/[postId].tsx index d1f467d..ac61b16 100644 --- a/app/(tabs)/media/post/[postId].tsx +++ b/app/(tabs)/media/post/[postId].tsx @@ -327,6 +327,29 @@ export default function PostDetailScreen() { )} + {/* Spot chip */} + {post.spot && ( + + router.push(`/(tabs)/spots/${post.spot?._id}`)} + > + + + {post.spot.name} + + {(post.spot.city || post.spot.state) && ( + + {[post.spot.city, post.spot.state].filter(Boolean).join(', ')} + + )} + + + )} + {/* Comments Section */} @@ -565,6 +588,31 @@ const styles = StyleSheet.create({ fontSize: 15, lineHeight: 22, }, + spotSection: { + paddingHorizontal: 20, + paddingBottom: 16, + }, + spotChip: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + maxWidth: '100%', + gap: 6, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + borderColor: `${YELLOW}60`, + }, + spotChipText: { + fontSize: 14, + fontWeight: '600', + flexShrink: 1, + }, + spotChipMeta: { + fontSize: 13, + flexShrink: 1, + }, commentsSection: { paddingHorizontal: 20, paddingTop: 16, diff --git a/app/(tabs)/media/upload.tsx b/app/(tabs)/media/upload.tsx index 8142e70..6adef61 100644 --- a/app/(tabs)/media/upload.tsx +++ b/app/(tabs)/media/upload.tsx @@ -13,8 +13,10 @@ import { ActivityIndicator, Alert, Dimensions, + FlatList, Image, KeyboardAvoidingView, + Modal, Platform, Pressable, ScrollView, @@ -25,7 +27,7 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { type CreatePostData, createPost } from '@/lib/api/feed'; -import { getSportTypes, type SportType } from '@/lib/api/spots'; +import { getSportTypes, type SportType, type Spot, searchSpots } from '@/lib/api/spots'; import { createVideoEntry, uploadImageToS3, @@ -61,6 +63,13 @@ export default function UploadScreen() { const [trickInput, setTrickInput] = useState(''); const [visibility, setVisibility] = useState<'public' | 'homies' | 'private'>('public'); + // Spot tagging state + const [selectedSpot, setSelectedSpot] = useState(null); + const [spotModalVisible, setSpotModalVisible] = useState(false); + const [spotQuery, setSpotQuery] = useState(''); + const [spotResults, setSpotResults] = useState([]); + const [spotSearching, setSpotSearching] = useState(false); + // Upload state const [uploadStep, setUploadStep] = useState('idle'); const [uploadProgress, setUploadProgress] = useState(0); @@ -145,6 +154,45 @@ export default function UploadScreen() { setTricks(tricks.filter((t) => t !== trick)); }; + // Debounced spot search while the picker modal is open + useEffect(() => { + if (!spotModalVisible) return; + const query = spotQuery.trim(); + if (query.length < 2) { + setSpotResults([]); + setSpotSearching(false); + return; + } + let cancelled = false; + setSpotSearching(true); + const handle = setTimeout(async () => { + const response = await searchSpots(query); + if (!cancelled) { + setSpotResults(response.spots); + setSpotSearching(false); + } + }, 350); + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [spotQuery, spotModalVisible]); + + const openSpotModal = () => { + setSpotQuery(''); + setSpotResults([]); + setSpotModalVisible(true); + }; + + const handleSelectSpot = (spot: Spot) => { + setSelectedSpot(spot); + setSpotModalVisible(false); + }; + + const handleRemoveSpot = () => { + setSelectedSpot(null); + }; + const handleSubmit = async () => { if (!selectedFile) { setError('Please select a video or image to upload'); @@ -297,6 +345,7 @@ export default function UploadScreen() { visibility, duration: processedVideo.duration, aspectRatio, + spotId: selectedSpot?._id, }; const post = await createPost(postData); @@ -344,6 +393,7 @@ export default function UploadScreen() { sportTypes: selectedSports, tricks, visibility, + spotId: selectedSpot?._id, }; const post = await createPost(postData); @@ -527,6 +577,46 @@ export default function UploadScreen() { )} + {/* Tag a Spot */} + + Tag a Spot + {selectedSpot ? ( + + + + + {selectedSpot.name} + + {(selectedSpot.city || selectedSpot.state) && ( + + {[selectedSpot.city, selectedSpot.state].filter(Boolean).join(', ')} + + )} + + {!isUploading && ( + + + + )} + + ) : ( + + + + Tag a spot (optional) + + + + )} + + {/* Visibility */} Who can see this? @@ -625,6 +715,90 @@ export default function UploadScreen() { + + {/* Spot Picker Modal */} + setSpotModalVisible(false)} + > + + + Tag a Spot + setSpotModalVisible(false)} + > + + + + + + + + {spotQuery.length > 0 && ( + setSpotQuery('')} hitSlop={8}> + + + )} + + + {spotSearching ? ( + + + + ) : ( + item._id} + keyboardShouldPersistTaps="handled" + contentContainerStyle={styles.modalListContent} + renderItem={({ item }) => ( + handleSelectSpot(item)} + > + + + + {item.name} + + {(item.city || item.state) && ( + + {[item.city, item.state].filter(Boolean).join(', ')} + + )} + + + )} + ListEmptyComponent={ + + + {spotQuery.trim().length < 2 + ? 'Type at least 2 characters to search.' + : 'No spots found.'} + + + } + /> + )} + + ); } @@ -885,4 +1059,97 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, + tagSpotButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingHorizontal: 16, + paddingVertical: 14, + borderRadius: 12, + }, + tagSpotButtonText: { + flex: 1, + fontSize: 15, + fontWeight: '500', + }, + spotChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: `${YELLOW}60`, + }, + spotChipInfo: { + flex: 1, + }, + spotChipName: { + fontSize: 15, + fontWeight: '600', + }, + spotChipMeta: { + fontSize: 13, + marginTop: 2, + }, + modalContainer: { + flex: 1, + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + }, + modalTitle: { + fontSize: 18, + fontWeight: '600', + }, + modalSearchRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginHorizontal: 16, + marginBottom: 12, + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 12, + backgroundColor: 'rgba(255,255,255,0.08)', + }, + modalSearchInput: { + flex: 1, + fontSize: 15, + }, + modalListContent: { + paddingHorizontal: 16, + }, + spotResultRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 14, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + spotResultInfo: { + flex: 1, + }, + spotResultName: { + fontSize: 15, + fontWeight: '600', + }, + spotResultMeta: { + fontSize: 13, + marginTop: 2, + }, + modalEmpty: { + paddingTop: 40, + alignItems: 'center', + }, + modalEmptyText: { + fontSize: 14, + textAlign: 'center', + paddingHorizontal: 40, + }, }); diff --git a/app/(tabs)/spots/[spotId].tsx b/app/(tabs)/spots/[spotId].tsx index 262720c..f35ee86 100644 --- a/app/(tabs)/spots/[spotId].tsx +++ b/app/(tabs)/spots/[spotId].tsx @@ -5,6 +5,7 @@ import { Ionicons } from '@expo/vector-icons'; import { Image as ExpoImage } from 'expo-image'; +import * as ImagePicker from 'expo-image-picker'; import { router, useLocalSearchParams } from 'expo-router'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { @@ -25,13 +26,16 @@ import { ShareToHomieModal } from '@/components/share'; import { AddToSpotListModal, SpotMap, SpotReviewsList } from '@/components/spots'; import { deleteSpot, + deleteSpotPhoto, getSpotById, isSpotSaved, + reportSpotPhoto, type Spot, type SpotPhoto, saveSpot, unsaveSpot, updateSpot, + uploadSpotPhoto, } from '@/lib/api/spots'; import { useThemeContext } from '@/lib/providers/ThemeProvider'; import { useAuthStore } from '@/lib/stores/authStore'; @@ -71,9 +75,14 @@ export default function SpotDetailScreen() { const [savingEdit, setSavingEdit] = useState(false); const [deleting, setDeleting] = useState(false); + // Photo upload state (Google-Maps-style "add a photo" for any logged-in user) + const [uploadingPhoto, setUploadingPhoto] = useState(false); + // Is the current user the author/owner of this spot? const currentUserId = user?.id || user?._id; const isOwner = !!currentUserId && !!spot?.userId && spot.userId === currentUserId; + const isAdmin = user?.role === 'admin'; + const isLoggedIn = !!currentUserId; // Combined photo gallery: user photos first, then Google photos, else header image const galleryPhotos = useMemo(() => { @@ -197,6 +206,168 @@ export default function SpotDetailScreen() { ); }, [spotId]); + // Upload one or more picked photos to this spot, then refetch to refresh gallery. + const uploadPickedPhotos = useCallback( + async (assets: { uri: string; mimeType: string }[]) => { + if (!spotId || assets.length === 0) return; + setUploadingPhoto(true); + let failed = 0; + for (const asset of assets) { + const uploaded = await uploadSpotPhoto(spotId, asset.uri, asset.mimeType); + if (!uploaded) failed++; + } + // Refetch the spot so the new photos show up in the gallery. + const refreshed = await getSpotById(spotId); + if (refreshed) setSpot(refreshed); + setUploadingPhoto(false); + if (failed > 0) { + Alert.alert( + 'Some photos failed', + `${failed} photo${failed > 1 ? 's' : ''} couldn't be uploaded. Please try again.`, + ); + } + }, + [spotId], + ); + + // Pick photos from the library and upload them. + const handleAddPhotoFromGallery = useCallback(async () => { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Permission Required', 'Please allow access to your photo library.'); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsMultipleSelection: true, + quality: 0.8, + }); + if (!result.canceled && result.assets.length > 0) { + await uploadPickedPhotos( + result.assets.map((asset) => ({ + uri: asset.uri, + mimeType: asset.mimeType || 'image/jpeg', + })), + ); + } + }, [uploadPickedPhotos]); + + // Capture a photo with the camera and upload it. + const handleAddPhotoFromCamera = useCallback(async () => { + const { status } = await ImagePicker.requestCameraPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Permission Required', 'Please allow access to your camera.'); + return; + } + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ['images'], + quality: 0.8, + }); + if (!result.canceled && result.assets[0]) { + const asset = result.assets[0]; + await uploadPickedPhotos([{ uri: asset.uri, mimeType: asset.mimeType || 'image/jpeg' }]); + } + }, [uploadPickedPhotos]); + + // "Add Photo" affordance — any logged-in user. Offers Gallery or Camera. + const handleAddPhoto = useCallback(() => { + if (!isLoggedIn) { + Alert.alert('Sign In Required', 'Please sign in to add a photo to this spot.', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Sign In', onPress: () => router.push('/(auth)/login') }, + ]); + return; + } + if (uploadingPhoto) return; + Alert.alert('Add Photo', 'Add a photo to this spot', [ + { text: 'Choose from Gallery', onPress: handleAddPhotoFromGallery }, + { text: 'Take Photo', onPress: handleAddPhotoFromCamera }, + { text: 'Cancel', style: 'cancel' }, + ]); + }, [isLoggedIn, uploadingPhoto, handleAddPhotoFromGallery, handleAddPhotoFromCamera]); + + // Report a user photo, then thank the reporter. + const handleReportPhoto = useCallback( + (photoKey: string) => { + if (!spotId) return; + Alert.alert('Report Photo', 'Report this photo as inappropriate or incorrect?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Report', + style: 'destructive', + onPress: async () => { + const ok = await reportSpotPhoto(spotId, photoKey); + if (ok) { + // A report may have crossed the auto-hide threshold — refresh so a + // now-hidden photo disappears. + const refreshed = await getSpotById(spotId); + if (refreshed) setSpot(refreshed); + } + Alert.alert( + ok ? 'Thanks' : 'Error', + ok ? "Thanks — we'll review this." : 'Failed to report photo. Please try again.', + ); + }, + }, + ]); + }, + [spotId], + ); + + // Delete a user photo (uploader/owner or admin), then refetch. + const handleDeletePhoto = useCallback( + (photoKey: string) => { + if (!spotId) return; + Alert.alert('Delete Photo', 'Delete this photo? This cannot be undone.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + const ok = await deleteSpotPhoto(spotId, photoKey); + if (ok) { + const refreshed = await getSpotById(spotId); + if (refreshed) setSpot(refreshed); + } else { + Alert.alert('Error', 'Failed to delete photo. Please try again.'); + } + }, + }, + ]); + }, + [spotId], + ); + + // Long-press / "..." on a user photo opens the report/delete menu. + const handlePhotoActions = useCallback( + (photo: SpotPhoto) => { + // Google photos have no key and are neither reportable nor deletable. + if (!photo.key) return; + const photoKey = photo.key; + const isOwnPhoto = !!currentUserId && photo.userId === currentUserId; + const canDelete = isAdmin || isOwnPhoto; + const buttons: { + text: string; + style?: 'default' | 'cancel' | 'destructive'; + onPress?: () => void; + }[] = []; + // You can't report your own photo — you delete it instead. + if (!isOwnPhoto) { + buttons.push({ text: 'Report photo', onPress: () => handleReportPhoto(photoKey) }); + } + if (canDelete) { + buttons.push({ + text: 'Delete', + style: 'destructive', + onPress: () => handleDeletePhoto(photoKey), + }); + } + buttons.push({ text: 'Cancel', style: 'cancel' }); + Alert.alert('Photo', undefined, buttons); + }, + [isAdmin, currentUserId, handleReportPhoto, handleDeletePhoto], + ); + // Parse tags into features array const getFeatures = (): string[] => { if (!spot?.tags) return []; @@ -311,6 +482,17 @@ export default function SpotDetailScreen() { {/* Action buttons */} + + {uploadingPhoto ? ( + + ) : ( + + )} + setIsFavorite(!isFavorite)}> - {/* Photo Gallery (user + Google photos) */} - {galleryPhotos.length > 1 && ( - - {galleryPhotos.map((photo, index) => ( - + {/* Skip index 0 — it's already shown as the large header image. */} + {galleryPhotos.slice(1).map((photo, index) => { + // Only user photos (which carry a `key`) can be reported/deleted. + const isUserPhoto = !!photo.key; + return ( + - ))} - - )} + onLongPress={isUserPhoto ? () => handlePhotoActions(photo) : undefined} + delayLongPress={300} + > + + {isUserPhoto && ( + handlePhotoActions(photo)} + hitSlop={8} + > + + + )} + + ); + })} + + {/* Add Photo tile — available to any logged-in user */} + + {uploadingPhoto ? ( + + ) : ( + <> + + + Add Photo + + + )} + + {/* Content */} @@ -845,6 +1065,31 @@ const styles = StyleSheet.create({ height: 80, borderRadius: 12, }, + galleryMoreButton: { + position: 'absolute', + top: 6, + right: 6, + width: 26, + height: 26, + borderRadius: 13, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + galleryAddTile: { + width: 110, + height: 80, + borderRadius: 12, + borderWidth: 1, + borderStyle: 'dashed', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + }, + galleryAddText: { + fontSize: 12, + fontWeight: '600', + }, // Content content: { diff --git a/src/lib/api/feed.ts b/src/lib/api/feed.ts index 77583cf..ab45d35 100644 --- a/src/lib/api/feed.ts +++ b/src/lib/api/feed.ts @@ -52,6 +52,16 @@ export interface FeedPost { status: 'processing' | 'published' | 'removed'; userReactions?: ('love' | 'respect')[]; saved?: boolean; + // Linked spot (backend enriches the post with this when spotId is set) + spotId?: string; + spot?: { + _id: string; + name: string; + city?: string; + state?: string; + imageURL?: string | null; + category?: string; + }; createdAt: string; updatedAt: string; } @@ -240,6 +250,7 @@ export interface CreatePostData { duration?: number; aspectRatio?: string; visibility?: 'public' | 'homies' | 'private'; + spotId?: string; } /** diff --git a/src/lib/api/spots.ts b/src/lib/api/spots.ts index bf3ce24..5f848c2 100644 --- a/src/lib/api/spots.ts +++ b/src/lib/api/spots.ts @@ -417,6 +417,37 @@ export async function uploadSpotPhoto( } } +/** + * Report a user-uploaded spot photo for review (any authenticated user). + * Returns true on success. + */ +export async function reportSpotPhoto(spotId: string, photoKey: string): Promise { + try { + // The S3 key contains a "/" (e.g. "spots/.jpg"); encode it so it stays + // a single path segment and the :photoKey route matches. + await apiClient.post( + `${ENDPOINTS.spots.detail(spotId)}/photos/${encodeURIComponent(photoKey)}/report`, + ); + return true; + } catch (_error) { + return false; + } +} + +/** + * Delete a user-uploaded spot photo (uploader/owner or admin). Returns true on success. + */ +export async function deleteSpotPhoto(spotId: string, photoKey: string): Promise { + try { + await apiClient.delete( + `${ENDPOINTS.spots.detail(spotId)}/photos/${encodeURIComponent(photoKey)}`, + ); + return true; + } catch (_error) { + return false; + } +} + // Google Places integration types export interface PlaceSearchResult { placeId: string; From ac73849ba28b3f83db6c46f0ebd05ab0966790e3 Mon Sep 17 00:00:00 2001 From: wbaxterh Date: Sat, 11 Jul 2026 23:18:37 -0700 Subject: [PATCH 2/5] fix(spots map): stop the Fabric touch-registry crash + steadier markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the hard crash on the spots map: NSInternalInconsistencyException: Inconsistency between local and UIKit touch registries (RCTSurfaceTouchHandler, New Architecture / iOS 26) Root cause (RN #53303): the projected-overlay marker Pressables were UNMOUNTED mid-gesture — projectToScreen returned null for off-screen points and the whole marker tree re-rendered on every onRegionChange frame — so a marker with a live UIKit touch disappeared and desynced Fabric's touch registry. "A lot of data" (many markers crossing the cull boundary) made it near-certain. Fixes: - projectToScreenXY: never returns null for off-screen points; markers stay MOUNTED and are hidden via opacity + pointerEvents instead of unmounting. - Stable, position-derived cluster keys (supercluster's cluster_id is reassigned across queries → key churn → remount). - Marker onPress defers its animateToRegion to the next frame so re-projection can't move/unmount the pressed marker while the touch is finalizing. - Safety net: patch-package neutralizes the fatal RCTAssert in RCTSurfaceTouchHandler (the guard right below it already skips the stray touch) — turns any residual desync into a no-op instead of a crash. Deep on-device testing still required (see PR notes): iOS 26 physical device, dense data, aggressive pan/pinch + tap-during-gesture. Co-Authored-By: Claude Fable 5 --- app/(tabs)/spots/index.tsx | 61 ++++++++++++++++++++----------- patches/react-native+0.81.5.patch | 40 ++++++++++++++++++++ src/hooks/useMapClusters.ts | 6 ++- src/lib/mapProjection.ts | 31 ++++++++++++++++ 4 files changed, 115 insertions(+), 23 deletions(-) create mode 100644 patches/react-native+0.81.5.patch diff --git a/app/(tabs)/spots/index.tsx b/app/(tabs)/spots/index.tsx index 4f23229..2bbfed3 100644 --- a/app/(tabs)/spots/index.tsx +++ b/app/(tabs)/spots/index.tsx @@ -44,7 +44,7 @@ import { saveSpot, unsaveSpot, } from '@/lib/api/spots'; -import { projectToScreen } from '@/lib/mapProjection'; +import { projectToScreenXY } from '@/lib/mapProjection'; import { useThemeContext } from '@/lib/providers/ThemeProvider'; import { useAuthStore } from '@/lib/stores/authStore'; import type { CreateSpotListInput, SpotList } from '@/types/spots'; @@ -645,36 +645,46 @@ export default function SpotsScreen() { {mapReady && mapLayout.width > 0 && ( {clusters.map((item) => { - const pt = projectToScreen( + const pt = projectToScreenXY( item.latitude, item.longitude, projectionRegion, mapLayout, ); if (!pt) return null; + // NEVER unmount an off-screen marker while panning — that + // desyncs Fabric's touch registry and hard-crashes on the New + // Architecture (RN #53303). Keep it mounted but hidden and + // non-interactive instead. + const hidden = !pt.onScreen; if (item.type === 'cluster') { return ( - {/* Selected spot card or spots preview — lifted above the bottom + {/* Selected-spot card — shown ONLY when a pin is tapped. Tapping + the map clears the selection (onPress on MapView) and hides it; + tapping another pin swaps in that spot. Lifted above the bottom safe area when fullscreen (no tab bar padding then). */} - - {selectedSpot ? ( + {selectedSpot && ( + handleToggleSave(selectedSpot)} onOpenListPicker={() => handleOpenListPicker(selectedSpot)} /> - ) : spots.length > 0 ? ( - - {spots.slice(0, 5).map((spot) => ( - { - setSelectedSpot(spot); - mapRef.current?.animateToRegion( - { - latitude: spot.latitude, - longitude: spot.longitude, - latitudeDelta: 0.05, - longitudeDelta: 0.05, - }, - 500, - ); - }} - onToggleSave={() => handleToggleSave(spot)} - onOpenListPicker={() => handleOpenListPicker(spot)} - /> - ))} - - ) : null} - + + )} ) : ( // List View