diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index 35ee779518d0..88a89f640e08 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -13,6 +13,7 @@ import {SafeAreaProvider, initialWindowMetrics} from 'react-native-safe-area-con import {makeEngine} from '../engine' import {GestureHandlerRootView} from 'react-native-gesture-handler' import {enableFreeze} from 'react-native-screens' +import {Image as ExpoImage} from 'expo-image' import {setKeyboardUp} from '@/styles/keyboard-state' import {setServiceDecoration} from '@/common-adapters/markdown/react' import ServiceDecoration from '@/common-adapters/markdown/service-decoration' @@ -27,6 +28,10 @@ logger.info('INIT App index module load') enableFreeze(true) setServiceDecoration(ServiceDecoration) +// SDWebImage (used by expo-image) flushes its memory cache on iOS memory warnings, but +// the simulator never sends memory warnings. Cap the cache so loading hundreds of chat +// images doesn't exhaust VM in the simulator. On a real device this is a safety net only. +ExpoImage.configureCache({maxMemoryCost: 100 * 1024 * 1024}) module.hot?.accept(() => { console.log('accepted update in shared/index.native') diff --git a/shared/chat/audio/audio-player.tsx b/shared/chat/audio/audio-player.tsx index 6127f19e7dac..ab41861b3830 100644 --- a/shared/chat/audio/audio-player.tsx +++ b/shared/chat/audio/audio-player.tsx @@ -69,8 +69,13 @@ const AudioPlayer = (props: Props) => { const {duration, big, maxWidth, url, visAmps} = props const [playedRatio, setPlayedRatio] = React.useState(0) const [paused, setPaused] = React.useState(true) + // Only mount AudioVideo after the user first taps play; calling useAudioPlayer + // unconditionally for every message in the list spawns CoreMedia threads per + // message and exhausts VM memory. + const [everPlayed, setEverPlayed] = React.useState(false) const onClick = () => { if (paused) { + setEverPlayed(true) setPaused(false) } else { setPaused(true) @@ -104,7 +109,7 @@ const AudioPlayer = (props: Props) => { {formatAudioRecordDuration(timeLeft)} - {url.length > 0 && ( + {url.length > 0 && everPlayed && ( )} diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index a47dccecc3f9..fb5690b40212 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -476,6 +476,7 @@ const NativeConversationList = function NativeConversationList() { const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions() + const keyExtractor = (ordinal: ItemType) => { return String(ordinal) } @@ -617,6 +618,7 @@ const NativeConversationList = function NativeConversationList() { keyExtractor={keyExtractor} ref={listRef} renderScrollComponent={renderScrollComponent} + windowSize={3} maintainVisibleContentPosition={ // MUST do this else if you come into a new thread it'll slowly scroll down when it loads numOrdinals ? maintainVisibleContentPosition : undefined diff --git a/shared/chat/conversation/messages/attachment/video/videoimpl.tsx b/shared/chat/conversation/messages/attachment/video/videoimpl.tsx index 52207a2b4b42..dd9f23b1def8 100644 --- a/shared/chat/conversation/messages/attachment/video/videoimpl.tsx +++ b/shared/chat/conversation/messages/attachment/video/videoimpl.tsx @@ -53,31 +53,60 @@ const DesktopVideoImpl = (p: Props) => { ) } -const NativeVideoImpl = (p: Props) => { - const {allowPlay, message, showPopup} = p - const {fileURL: url, transferState, videoDuration} = message - const {previewURL, height, width} = getAttachmentPreviewSize(message) - const {showPoster, reveal} = usePosterState(url) - const sourceUri = `${url}&contentforce=true` +// Separated into its own component so useVideoPlayer is only called when the +// user actually taps play. Calling useVideoPlayer unconditionally for every +// video message in the list caused CoreMedia to initialize a player per +// message, spawning dozens of network threads and exhausting VM memory. +type NativeActiveVideoProps = { + sourceUri: string + height: number + width: number +} +const NativeActiveVideo = (p: NativeActiveVideoProps) => { + const {sourceUri, height, width} = p const player = useVideoPlayer(sourceUri, pl => { pl.loop = false + pl.play() }) - - const onPress = () => { - reveal() - player.play() - } - useEventListener(player, 'playToEnd', () => { player.replay() }) + return ( + + ) +} + +const NativeVideoImpl = (p: Props) => { + const {allowPlay, message, showPopup} = p + const {fileURL: url, transferState, videoDuration} = message + const {previewURL, height, width} = getAttachmentPreviewSize(message) + const [playerActive, setPlayerActive] = React.useState(false) + const [lastUrl, setLastUrl] = React.useState(url) + if (lastUrl !== url) { + setLastUrl(url) + setPlayerActive(false) + } + const sourceUri = `${url}&contentforce=true` return ( <> - {showPoster ? ( - + {playerActive && url ? ( + + ) : ( + { + if (allowPlay) setPlayerActive(true) + }} + style={nativeStyles.pressable} + onLongPress={showPopup} + > { - ) : ( - )} ) diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/video.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/video.tsx index cc0ef41e7069..37c2424dd184 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/video.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/video.tsx @@ -53,12 +53,18 @@ const DesktopVideo = (p: Props) => { ) } -const NativeVideo = (props: Props) => { - const {autoPlay, onClick, url, style, width, height} = props - const {playing, setPlaying} = usePlayState(url, autoPlay) +// Separated into its own component so useVideoPlayer is only called when the +// player is needed. Calling it unconditionally for every unfurl in the list +// spawns CoreMedia threads per message and exhausts VM memory. +type NativeActiveVideoProps = { + sourceUri: string + autoPlay: boolean + playing: boolean + style: object +} - const uri = url.length > 0 ? url : 'https://' - const sourceUri = `${uri}&autoplay=${autoPlay ? 'true' : 'false'}&contentforce=true` +const NativeActiveVideo = (props: NativeActiveVideoProps) => { + const {sourceUri, autoPlay, playing, style} = props const player = useVideoPlayer(sourceUri, p => { p.loop = true @@ -85,22 +91,50 @@ const NativeVideo = (props: Props) => { return () => sub.remove() }, [player]) + return ( + + ) +} + +const NativeVideo = (props: Props) => { + const {autoPlay, onClick, url, style, width, height} = props + const {playing, setPlaying} = usePlayState(url, autoPlay) + // Activate the player when autoPlay is true or the user first taps play. + // Reset when URL changes so a new source gets a fresh player. + const [active, setActive] = React.useState(autoPlay) + const [lastUrl, setLastUrl] = React.useState(url) + if (lastUrl !== url) { + setLastUrl(url) + setActive(autoPlay) + } + + const uri = url.length > 0 ? url : 'https://' + const sourceUri = `${uri}&autoplay=${autoPlay ? 'true' : 'false'}&contentforce=true` + const _onClick = () => { if (onClick) { onClick() return } + setActive(true) setPlaying(p => !p) } return ( - + {active && ( + + )} {!playing && }