From 3306cb182181a04fd335276e7c31c94aaf08f85f Mon Sep 17 00:00:00 2001 From: Kevin Hernandez <115322555+kevinherdez@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:54:43 -0700 Subject: [PATCH 1/2] Fix MediaPreview Error State --- components/content/QueryState.tsx | 2 -- components/content/carousel/MediaPreview.tsx | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/components/content/QueryState.tsx b/components/content/QueryState.tsx index a59e6515..13e21596 100644 --- a/components/content/QueryState.tsx +++ b/components/content/QueryState.tsx @@ -54,7 +54,6 @@ export const QueryState: React.FunctionComponent = ({results, t const isResultNotFound = (result: UseQueryResult): boolean => result.isError && result.error instanceof NotFoundError; export const InternalError: React.FunctionComponent<{inline?: boolean}> = ({inline}) => { - // const navigation = useNavigation(); return ( = ({inli illustrationBottomMargin={-64} illustrationLeftMargin={-16} inline={inline} - // onClose={() => navigation.navigate('Home')} // TODO(skuznets): figure out how to navigate home here, as we don't have the props needed to go home - can we go to defaults for tab navigator? /> ); }; diff --git a/components/content/carousel/MediaPreview.tsx b/components/content/carousel/MediaPreview.tsx index 21fec8ec..68b59fe6 100644 --- a/components/content/carousel/MediaPreview.tsx +++ b/components/content/carousel/MediaPreview.tsx @@ -1,6 +1,7 @@ import {MediaViewerModal} from 'components/content/carousel/MediaViewerModal/MediaViewerModal'; import {NetworkImage} from 'components/content/carousel/NetworkImage'; import {imageToThumbnailListItem, ThumbnailListItem, videoToThumbnailListItem} from 'components/content/carousel/ThumbnailList'; +import {InternalError} from 'components/content/QueryState'; import {View, ViewProps, VStack} from 'components/core'; import {HTML, HTMLRendererConfig} from 'components/text/HTML'; import React, {useCallback, useMemo, useState} from 'react'; @@ -40,7 +41,11 @@ export const MediaPreview: React.FunctionComponent = ({thumbn const thumbnailItem = useMemo(() => thumbnailListItem(mediaItem), [mediaItem]); if (!thumbnailItem) { - return ; + return ( + + + + ); } return ( From c0d1016131b4858fdcdf4d3294c4b45800da9926 Mon Sep 17 00:00:00 2001 From: Kevin Hernandez <115322555+kevinherdez@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:57:36 -0700 Subject: [PATCH 2/2] Support PDF viewing --- components/content/carousel/MediaPreview.tsx | 29 +++-- .../carousel/MediaViewerModal/ImageView.tsx | 2 +- .../MediaViewerModal/MediaContentView.tsx | 14 +-- .../carousel/MediaViewerModal/PDFView.tsx | 28 +++++ .../MediaViewerModal/WebMediaView.tsx | 104 ++++++++++++++++++ .../MediaViewerModal/WebVideoView.tsx | 91 ++------------- .../MediaViewerModal/webViewOfflineError.ts | 8 ++ components/content/carousel/PDFThumbnail.tsx | 35 ++++++ components/content/carousel/ThumbnailList.tsx | 48 +++++--- components/screens/menu/DeveloperMenu.tsx | 9 ++ types/nationalAvalancheCenter/schemas.ts | 1 + 11 files changed, 255 insertions(+), 114 deletions(-) create mode 100644 components/content/carousel/MediaViewerModal/PDFView.tsx create mode 100644 components/content/carousel/MediaViewerModal/WebMediaView.tsx create mode 100644 components/content/carousel/MediaViewerModal/webViewOfflineError.ts create mode 100644 components/content/carousel/PDFThumbnail.tsx diff --git a/components/content/carousel/MediaPreview.tsx b/components/content/carousel/MediaPreview.tsx index 68b59fe6..b2ea54f3 100644 --- a/components/content/carousel/MediaPreview.tsx +++ b/components/content/carousel/MediaPreview.tsx @@ -1,6 +1,7 @@ import {MediaViewerModal} from 'components/content/carousel/MediaViewerModal/MediaViewerModal'; import {NetworkImage} from 'components/content/carousel/NetworkImage'; -import {imageToThumbnailListItem, ThumbnailListItem, videoToThumbnailListItem} from 'components/content/carousel/ThumbnailList'; +import {PDFThumbnail} from 'components/content/carousel/PDFThumbnail'; +import {imageToThumbnailListItem, pdfToThumbnailListItem, ThumbnailListItem, videoToThumbnailListItem} from 'components/content/carousel/ThumbnailList'; import {InternalError} from 'components/content/QueryState'; import {View, ViewProps, VStack} from 'components/core'; import {HTML, HTMLRendererConfig} from 'components/text/HTML'; @@ -17,6 +18,10 @@ const thumbnailListItem = (mediaItem: MediaItem): ThumbnailListItem | undefined return imageToThumbnailListItem(mediaItem); } + if (mediaItem.type === MediaType.PDF) { + return pdfToThumbnailListItem(mediaItem); + } + return undefined; }; @@ -51,15 +56,19 @@ export const MediaPreview: React.FunctionComponent = ({thumbn return ( - + {thumbnailItem.kind === 'pdf' ? ( + + ) : ( + + )} {thumbnailItem.caption && ( diff --git a/components/content/carousel/MediaViewerModal/ImageView.tsx b/components/content/carousel/MediaViewerModal/ImageView.tsx index b79ea476..5e81e7da 100644 --- a/components/content/carousel/MediaViewerModal/ImageView.tsx +++ b/components/content/carousel/MediaViewerModal/ImageView.tsx @@ -158,7 +158,7 @@ export const ImageView: React.FunctionComponent = ({item, native return ( - + ); diff --git a/components/content/carousel/MediaViewerModal/MediaContentView.tsx b/components/content/carousel/MediaViewerModal/MediaContentView.tsx index 17839899..7e757e34 100644 --- a/components/content/carousel/MediaViewerModal/MediaContentView.tsx +++ b/components/content/carousel/MediaViewerModal/MediaContentView.tsx @@ -1,17 +1,14 @@ import {ImageView} from 'components/content/carousel/MediaViewerModal/ImageView'; +import {PDFView} from 'components/content/carousel/MediaViewerModal/PDFView'; import {WebVideoView} from 'components/content/carousel/MediaViewerModal/WebVideoView'; import {View} from 'components/core'; import {BodySm} from 'components/text'; import {usePostHog} from 'posthog-react-native'; import React, {useEffect} from 'react'; -import {Dimensions} from 'react-native'; +import {useWindowDimensions} from 'react-native'; import {NativeGesture} from 'react-native-gesture-handler'; import {MediaItem, MediaType} from 'types/nationalAvalancheCenter'; -const SCREEN = Dimensions.get('screen'); -const SCREEN_WIDTH = SCREEN.width; -const SCREEN_HEIGHT = SCREEN.height; - interface MediaContentProps { item: MediaItem; isVisible: boolean; @@ -20,14 +17,17 @@ interface MediaContentProps { export const MediaContentView: React.FunctionComponent = ({item, isVisible, nativeGesture}) => { const postHog = usePostHog(); + const dimensions = useWindowDimensions(); let content: React.JSX.Element; let isMediaSupported = true; if (item.type === MediaType.Image) { - content = ; + content = ; } else if (item.type === MediaType.Video) { content = ; + } else if (item.type === MediaType.PDF) { + content = ; } else { isMediaSupported = false; content = {'Unsupported Media Type'}; @@ -39,5 +39,5 @@ export const MediaContentView: React.FunctionComponent = ({it } }, [postHog, isMediaSupported, item]); - return {content}; + return {content}; }; diff --git a/components/content/carousel/MediaViewerModal/PDFView.tsx b/components/content/carousel/MediaViewerModal/PDFView.tsx new file mode 100644 index 00000000..f5e5f914 --- /dev/null +++ b/components/content/carousel/MediaViewerModal/PDFView.tsx @@ -0,0 +1,28 @@ +import {WebMediaView} from 'components/content/carousel/MediaViewerModal/WebMediaView'; +import {usePostHog} from 'posthog-react-native'; +import React, {useEffect} from 'react'; +import {Platform} from 'react-native'; +import {WebViewSource} from 'react-native-webview/lib/WebViewTypes'; +import {PDFMediaItem} from 'types/nationalAvalancheCenter'; + +interface PDFViewProps { + item: PDFMediaItem; +} + +const googleDocsViewer = (url: string) => `https://docs.google.com/gview?embedded=true&url=${encodeURIComponent(url)}`; + +const getPdfSource = (item: PDFMediaItem): WebViewSource => ({ + uri: Platform.OS === 'android' ? googleDocsViewer(item.url.original) : item.url.original, +}); + +export const PDFView: React.FunctionComponent = ({item}: PDFViewProps) => { + const postHog = usePostHog(); + + useEffect(() => { + if (postHog) { + postHog.capture('pdfView-Opened', {url: item.url.original}); + } + }, [postHog, item.url.original]); + + return ; +}; diff --git a/components/content/carousel/MediaViewerModal/WebMediaView.tsx b/components/content/carousel/MediaViewerModal/WebMediaView.tsx new file mode 100644 index 00000000..b2634e00 --- /dev/null +++ b/components/content/carousel/MediaViewerModal/WebMediaView.tsx @@ -0,0 +1,104 @@ +import {onlineManager} from '@tanstack/react-query'; +import {MediaLoadErrorView} from 'components/content/carousel/MediaViewerModal/MediaLoadErrorView'; +import {isOfflineErrorCode} from 'components/content/carousel/MediaViewerModal/webViewOfflineError'; +import {View} from 'components/core'; +import React, {Ref, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {ActivityIndicator, LayoutChangeEvent, StyleSheet} from 'react-native'; +import WebView from 'react-native-webview'; +import {WebViewErrorEvent, WebViewSource} from 'react-native-webview/lib/WebViewTypes'; +import {colorLookup} from 'theme'; + +export interface WebMediaViewHandle { + reload: () => void; + injectJavaScript: (script: string) => void; +} + +export interface WebMediaViewProps { + ref?: Ref; + source: WebViewSource; + heightFraction: number; + errorMessage: string; + allowsInlineMediaPlayback?: boolean; + allowsFullscreenVideo?: boolean; + scalesPageToFit?: boolean; +} + +export const WebMediaView: React.FunctionComponent = ({ + ref, + source, + heightFraction, + errorMessage, + allowsInlineMediaPlayback, + allowsFullscreenVideo, + scalesPageToFit, +}) => { + const webRef = useRef(null); + + const [containerHeight, setContainerHeight] = useState(0); + const [loadError, setLoadError] = useState(null); + + const onContainerLayout = useCallback((event: LayoutChangeEvent) => { + setContainerHeight(event.nativeEvent.layout.height); + }, []); + + useImperativeHandle( + ref, + () => ({ + reload: () => webRef.current?.reload(), + injectJavaScript: (script: string) => webRef.current?.injectJavaScript(script), + }), + [], + ); + + useEffect(() => { + return onlineManager.subscribe(() => { + if (loadError && isOfflineErrorCode(loadError.nativeEvent.code) && onlineManager.isOnline()) { + webRef.current?.reload(); + setLoadError(null); + } + }); + }, [loadError]); + + const maxViewHeight = containerHeight * heightFraction; + + const onRenderLoading = useCallback(() => { + return ( + + + + ); + }, []); + + const onError = useCallback((error: WebViewErrorEvent) => { + setLoadError(error); + }, []); + + const onRetry = useCallback(() => { + webRef.current?.reload(); + setLoadError(null); + }, []); + + if (loadError) { + const message = isOfflineErrorCode(loadError.nativeEvent.code) ? "It appears you're not connected to the internet. Please reconnect and try again." : errorMessage; + return ; + } + + return ( + + + + + + ); +}; diff --git a/components/content/carousel/MediaViewerModal/WebVideoView.tsx b/components/content/carousel/MediaViewerModal/WebVideoView.tsx index 78954b1b..7d9184d2 100644 --- a/components/content/carousel/MediaViewerModal/WebVideoView.tsx +++ b/components/content/carousel/MediaViewerModal/WebVideoView.tsx @@ -1,19 +1,13 @@ -import {onlineManager} from '@tanstack/react-query'; -import {MediaLoadErrorView} from 'components/content/carousel/MediaViewerModal/MediaLoadErrorView'; -import {Center, View} from 'components/core'; +import {WebMediaView, WebMediaViewHandle} from 'components/content/carousel/MediaViewerModal/WebMediaView'; +import {Center} from 'components/core'; import {BodySm} from 'components/text'; import Constants from 'expo-constants'; import {usePostHog} from 'posthog-react-native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {ActivityIndicator, Dimensions, Platform} from 'react-native'; -import WebView from 'react-native-webview'; -import {WebViewErrorEvent, WebViewSource} from 'react-native-webview/lib/WebViewTypes'; -import {colorLookup} from 'theme'; +import React, {useEffect, useRef} from 'react'; +import {Platform} from 'react-native'; +import {WebViewSource} from 'react-native-webview/lib/WebViewTypes'; import {VideoMediaItem} from 'types/nationalAvalancheCenter'; -const SCREEN = Dimensions.get('screen'); -const SCREEN_HEIGHT = SCREEN.height; - interface WebVideoViewProps { item: VideoMediaItem; isVisible: boolean; @@ -51,47 +45,16 @@ const getBundleID = () => { const youtubeLink = (videoId: string) => `https://youtube.com/embed/${videoId}`; const refererValue = (bundleID: string) => `https://${bundleID}`; -const ANDROID_OFFLINE_ERROR_CODE = -2; -const IOS_OFFLINE_ERROR_CODE = -1009; - -const isOfflineErrorCode = (errorCode: number) => { - return (Platform.OS === 'android' && errorCode == ANDROID_OFFLINE_ERROR_CODE) || (Platform.OS === 'ios' && errorCode == IOS_OFFLINE_ERROR_CODE); -}; - export const WebVideoView: React.FunctionComponent = ({item, isVisible}: WebVideoViewProps) => { - const webRef = useRef(null); + const handleRef = useRef(null); const postHog = usePostHog(); - const [loadError, setLoadError] = useState(null); - - useEffect(() => { - return onlineManager.subscribe(() => { - if (loadError && isOfflineErrorCode(loadError.nativeEvent.code) && onlineManager.isOnline()) { - webRef.current?.reload(); - setLoadError(null); - } - }); - }, [loadError, webRef, setLoadError]); - useEffect(() => { if (!isVisible) { - const jsCode = `document.querySelector('video').pause();`; - webRef.current?.injectJavaScript(jsCode); + handleRef.current?.injectJavaScript(`document.querySelector('video').pause();`); } }, [isVisible]); - // This centers the video within the modal - const maxHeight = SCREEN_HEIGHT * 0.33; - const yOffset = (SCREEN_HEIGHT - maxHeight) / 2; - - const onRenderLoading = useCallback(() => { - return ( - - - - ); - }, [maxHeight]); - useEffect(() => { if (postHog) { let properties: {[key: string]: string} = {}; @@ -104,18 +67,6 @@ export const WebVideoView: React.FunctionComponent = ({item, } }, [postHog, item.url]); - const onError = useCallback( - (error: WebViewErrorEvent) => { - setLoadError(error); - }, - [setLoadError], - ); - - const onRetry = useCallback(() => { - webRef.current?.reload(); - setLoadError(null); - }, [webRef, setLoadError]); - let sourceData: WebViewSource; try { sourceData = getSourceData(item); @@ -127,34 +78,14 @@ export const WebVideoView: React.FunctionComponent = ({item, ); } - if (loadError) { - return ; - } - return ( - ); }; - -interface LoadErrorViewProps { - error: WebViewErrorEvent; - onRetry: () => void; -} - -const WebViewLoadError: React.FunctionComponent = ({error, onRetry}) => { - const message = isOfflineErrorCode(error.nativeEvent.code) - ? "It appears you're not connected to the internet. Please reconnect and try again." - : 'An error occured loading the video. Please try again'; - - return ; -}; diff --git a/components/content/carousel/MediaViewerModal/webViewOfflineError.ts b/components/content/carousel/MediaViewerModal/webViewOfflineError.ts new file mode 100644 index 00000000..1efbd432 --- /dev/null +++ b/components/content/carousel/MediaViewerModal/webViewOfflineError.ts @@ -0,0 +1,8 @@ +import {Platform} from 'react-native'; + +const ANDROID_OFFLINE_ERROR_CODE = -2; +const IOS_OFFLINE_ERROR_CODE = -1009; + +export const isOfflineErrorCode = (errorCode: number) => { + return (Platform.OS === 'android' && errorCode == ANDROID_OFFLINE_ERROR_CODE) || (Platform.OS === 'ios' && errorCode == IOS_OFFLINE_ERROR_CODE); +}; diff --git a/components/content/carousel/PDFThumbnail.tsx b/components/content/carousel/PDFThumbnail.tsx new file mode 100644 index 00000000..23cf3e37 --- /dev/null +++ b/components/content/carousel/PDFThumbnail.tsx @@ -0,0 +1,35 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import {NetworkImageState} from 'components/content/carousel/NetworkImage'; +import {Center} from 'components/core'; +import React, {useCallback, useEffect} from 'react'; +import {ImageStyle, StyleProp, StyleSheet, TouchableOpacity} from 'react-native'; +import {colorLookup} from 'theme/colors'; + +interface PDFThumbnailProps { + width: number; + height: number; + index: number; + imageStyle?: StyleProp; + onPress?: (index: number) => void; + onStateChange?: (index: number, state: NetworkImageState) => void; +} + +const pdfThumbnailStyle = {borderRadius: 16, borderColor: colorLookup('light.300'), borderWidth: 1, backgroundColor: colorLookup('light.100')}; + +export const PDFThumbnail: React.FC = ({width, height, index, imageStyle, onPress, onStateChange}) => { + useEffect(() => { + if (onStateChange) { + onStateChange(index, 'success'); + } + }, [index, onStateChange]); + + const onPressHandler = useCallback(() => onPress && onPress(index), [index, onPress]); + + return ( + +
+ +
+
+ ); +}; diff --git a/components/content/carousel/ThumbnailList.tsx b/components/content/carousel/ThumbnailList.tsx index cc7c7044..495023b8 100644 --- a/components/content/carousel/ThumbnailList.tsx +++ b/components/content/carousel/ThumbnailList.tsx @@ -1,12 +1,13 @@ import {NetworkImage, NetworkImageProps, NetworkImageState} from 'components/content/carousel/NetworkImage'; +import {PDFThumbnail} from 'components/content/carousel/PDFThumbnail'; import {VStack, View} from 'components/core'; import React, {PropsWithChildren, useCallback, useMemo, useState} from 'react'; import {FlatList, FlatListProps} from 'react-native'; -import {ImageMediaItem, MediaItem, MediaType, VideoMediaItem} from 'types/nationalAvalancheCenter'; +import {ImageMediaItem, MediaItem, MediaType, PDFMediaItem, VideoMediaItem} from 'types/nationalAvalancheCenter'; export interface ThumbnailListItem { + kind: 'image' | 'video' | 'pdf'; uri: string; - isVideo: boolean; caption: string | null; title: string | null | undefined; } @@ -20,6 +21,8 @@ const thumbnailListItems = (mediaItems: MediaItem[]): ThumbnailListItem[] => { thumbnailItems.push(videoToThumbnailListItem(item)); } else if (item.type === MediaType.Image) { thumbnailItems.push(imageToThumbnailListItem(item)); + } else if (item.type === MediaType.PDF) { + thumbnailItems.push(pdfToThumbnailListItem(item)); } }); @@ -29,8 +32,8 @@ const thumbnailListItems = (mediaItems: MediaItem[]): ThumbnailListItem[] => { export const videoToThumbnailListItem = (item: VideoMediaItem): ThumbnailListItem => { if (typeof item.url === 'string' || 'external_link' in item.url) { return { + kind: 'video', uri: '', - isVideo: true, caption: item.caption, title: item.title, }; @@ -38,8 +41,8 @@ export const videoToThumbnailListItem = (item: VideoMediaItem): ThumbnailListIte const url = item.url; return { + kind: 'video', uri: url['thumbnail'], - isVideo: true, caption: item.caption, title: item.title, }; @@ -47,13 +50,22 @@ export const videoToThumbnailListItem = (item: VideoMediaItem): ThumbnailListIte export const imageToThumbnailListItem = (item: ImageMediaItem): ThumbnailListItem => { return { + kind: 'image', uri: item.url['thumbnail'], - isVideo: false, caption: item.caption, title: item.title, }; }; +export const pdfToThumbnailListItem = (_item: PDFMediaItem): ThumbnailListItem => { + return { + kind: 'pdf', + uri: '', + caption: null, + title: undefined, + }; +}; + export interface ThumbnailListProps extends Omit, 'data' | 'renderItem'> { imageHeight: number; imageWidth: number; @@ -93,17 +105,21 @@ export const ThumbnailList: React.FC> = ({ const renderItem = useCallback( ({item, index}: {item: ThumbnailListItem; index: number}) => ( - + {item.kind === 'pdf' ? ( + + ) : ( + + )} ), [imageWidth, imageHeight, imageStyle, resizeMode, onPress, onStateCallback], diff --git a/components/screens/menu/DeveloperMenu.tsx b/components/screens/menu/DeveloperMenu.tsx index e444a1b1..c5f9ec00 100644 --- a/components/screens/menu/DeveloperMenu.tsx +++ b/components/screens/menu/DeveloperMenu.tsx @@ -483,6 +483,15 @@ export const DeveloperMenu: React.FC = ({staging, setStaging }); }, }, + { + label: '10: with PDF', + data: null, + action: () => { + navigation.navigate('observation', { + id: 'a1af8dc3-ba24-403c-a87b-43e94796361d', + }); + }, + }, { label: 'NWAC pro observation with avalanches', data: null, diff --git a/types/nationalAvalancheCenter/schemas.ts b/types/nationalAvalancheCenter/schemas.ts index a68b2583..2211d1d7 100644 --- a/types/nationalAvalancheCenter/schemas.ts +++ b/types/nationalAvalancheCenter/schemas.ts @@ -384,6 +384,7 @@ export const pdfMediaSchema = z.object({ original: z.string().url(), }), }); +export type PDFMediaItem = z.infer; const unknownMediaSchema = z.object({ type: z.literal(MediaType.Unknown),