diff --git a/native/media/encrypted-image.react.js b/native/media/encrypted-image.react.js index f3d0f25ce..743b2804e 100644 --- a/native/media/encrypted-image.react.js +++ b/native/media/encrypted-image.react.js @@ -1,109 +1,109 @@ // @flow import * as React from 'react'; import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; import { decryptBase64, decryptMedia } from './encryption-utils.js'; import LoadableImage from './loadable-image.react.js'; import { useSelector } from '../redux/redux-utils.js'; import type { ImageStyle } from '../types/styles.js'; type BaseProps = { +holder: string, +encryptionKey: string, - +onLoad: (uri: string) => void, + +onLoad?: (uri: string) => void, +spinnerColor: string, +style: ImageStyle, +invisibleLoad: boolean, +thumbHash?: ?string, }; type Props = { ...BaseProps, }; function EncryptedImage(props: Props): React.Node { const { holder, encryptionKey, onLoad: onLoadProp, thumbHash: encryptedThumbHash, } = props; const mediaCache = React.useContext(MediaCacheContext); const [source, setSource] = React.useState(null); const connectionStatus = useSelector(state => state.connection.status); const prevConnectionStatusRef = React.useRef(connectionStatus); const [attempt, setAttempt] = React.useState(0); if (prevConnectionStatusRef.current !== connectionStatus) { if (!source && connectionStatus === 'connected') { setAttempt(attempt + 1); } prevConnectionStatusRef.current = connectionStatus; } const placeholder = React.useMemo(() => { if (!encryptedThumbHash) { return null; } try { const decryptedThumbHash = decryptBase64( encryptedThumbHash, encryptionKey, ); return { thumbhash: decryptedThumbHash }; } catch (e) { return null; } }, [encryptedThumbHash, encryptionKey]); React.useEffect(() => { let isMounted = true; setSource(null); const loadDecrypted = async () => { const cached = await mediaCache?.get(holder); if (cached && isMounted) { setSource({ uri: cached }); return; } const { result } = await decryptMedia(holder, encryptionKey, { destination: 'data_uri', }); // TODO: decide what to do if decryption fails if (result.success && isMounted) { mediaCache?.set(holder, result.uri); setSource({ uri: result.uri }); } }; loadDecrypted(); return () => { isMounted = false; }; }, [attempt, holder, encryptionKey, mediaCache]); const onLoad = React.useCallback(() => { onLoadProp && onLoadProp(holder); }, [holder, onLoadProp]); const { style, spinnerColor, invisibleLoad } = props; return ( ); } export default EncryptedImage; diff --git a/native/media/loadable-image.react.js b/native/media/loadable-image.react.js index cf2166cf8..57fc179bc 100644 --- a/native/media/loadable-image.react.js +++ b/native/media/loadable-image.react.js @@ -1,84 +1,84 @@ // @flow import { Image } from 'expo-image'; import * as React from 'react'; import { View, StyleSheet, ActivityIndicator } from 'react-native'; import type { ImageSource } from 'react-native/Libraries/Image/ImageSource'; import type { ImageStyle } from '../types/styles.js'; type Props = { +placeholder: ?ImageSource, +source: ?ImageSource, - +onLoad: () => void, + +onLoad?: () => void, +spinnerColor: string, +style: ImageStyle, +invisibleLoad: boolean, }; function LoadableImage(props: Props): React.Node { const { source, placeholder, onLoad: onLoadProp } = props; const [loaded, setLoaded] = React.useState(false); const onLoad = React.useCallback(() => { setLoaded(true); onLoadProp && onLoadProp(); }, [onLoadProp]); const invisibleStyle = React.useMemo( () => [props.style, styles.invisible], [props.style], ); if (!loaded && props.invisibleLoad) { return ( ); } let spinner; if (!loaded) { spinner = ( ); } return ( {spinner} ); } const styles = StyleSheet.create({ container: { flex: 1, }, invisible: { opacity: 0, }, spinnerContainer: { alignItems: 'center', bottom: 0, justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0, }, }); export default LoadableImage; diff --git a/native/media/multimedia.react.js b/native/media/multimedia.react.js index 1b525bc09..df41e4499 100644 --- a/native/media/multimedia.react.js +++ b/native/media/multimedia.react.js @@ -1,208 +1,213 @@ // @flow import { Image } from 'expo-image'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import type { MediaInfo, AvatarMediaInfo } from 'lib/types/media-types.js'; import EncryptedImage from './encrypted-image.react.js'; import RemoteImage from './remote-image.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; type Source = | { +kind: 'uri', +uri: string, +thumbHash?: ?string, } | { +kind: 'encrypted', +holder: string, +encryptionKey: string, +thumbHash?: ?string, }; type BaseProps = { +mediaInfo: MediaInfo | AvatarMediaInfo, +spinnerColor: string, }; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, }; type State = { +currentSource: Source, +departingSource: ?Source, }; class Multimedia extends React.PureComponent { static defaultProps = { spinnerColor: 'black', }; constructor(props: Props) { super(props); this.state = { currentSource: Multimedia.sourceFromMediaInfo(props.mediaInfo), departingSource: null, }; } get inputState() { const { inputState } = this.props; invariant(inputState, 'inputState should be set in Multimedia'); return inputState; } componentDidMount() { this.reportSourceDisplayed(this.state.currentSource, true); } componentWillUnmount() { const { currentSource, departingSource } = this.state; this.reportSourceDisplayed(currentSource, false); if (departingSource) { this.reportSourceDisplayed(departingSource, false); } } componentDidUpdate(prevProps: Props, prevState: State) { const newSource = Multimedia.sourceFromMediaInfo(this.props.mediaInfo); const oldSource = this.state.currentSource; if (!_isEqual(newSource)(oldSource)) { this.reportSourceDisplayed(newSource, true); const { departingSource } = this.state; if (departingSource && !_isEqual(oldSource)(departingSource)) { // If there's currently an existing departingSource, that means that // oldSource hasn't loaded yet. Since it's being replaced anyways // we don't need to display it anymore, so we can unlink it now this.reportSourceDisplayed(oldSource, false); this.setState({ currentSource: newSource }); } else { this.setState({ currentSource: newSource, departingSource: oldSource }); } } const newDepartingSource = this.state.departingSource; const oldDepartingSource = prevState.departingSource; if ( oldDepartingSource && !_isEqual(oldDepartingSource)(newDepartingSource) ) { this.reportSourceDisplayed(oldDepartingSource, false); } } render() { const images = []; const { currentSource, departingSource } = this.state; if (departingSource) { images.push(this.renderSource(currentSource, true)); - images.push(this.renderSource(departingSource, true)); + images.push(this.renderSource(departingSource, false, false)); } else { images.push(this.renderSource(currentSource)); } return {images}; } - renderSource(source: Source, invisibleLoad?: boolean = false) { + renderSource( + source: Source, + invisibleLoad?: boolean = false, + triggerOnLoad?: boolean = true, + ) { + const onLoadProp = triggerOnLoad ? this.onLoad : undefined; if (source.kind === 'encrypted') { return ( ); } const { uri, thumbHash } = source; const placeholder = thumbHash ? { thumbhash: thumbHash } : null; if (uri.startsWith('http')) { return ( ); } else { return ( ); } } onLoad = () => { this.setState({ departingSource: null }); }; reportSourceDisplayed = (source: Source, isLoaded: boolean) => { if (source.kind === 'uri') { this.inputState.reportURIDisplayed(source.uri, isLoaded); } }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return static sourceFromMediaInfo(mediaInfo: MediaInfo | AvatarMediaInfo): Source { if (mediaInfo.type === 'photo') { return { kind: 'uri', uri: mediaInfo.uri }; } else if (mediaInfo.type === 'video') { return { kind: 'uri', uri: mediaInfo.thumbnailURI }; } else if (mediaInfo.type === 'encrypted_photo') { return { kind: 'encrypted', holder: mediaInfo.holder, encryptionKey: mediaInfo.encryptionKey, }; } else if (mediaInfo.type === 'encrypted_video') { return { kind: 'encrypted', holder: mediaInfo.thumbnailHolder, encryptionKey: mediaInfo.thumbnailEncryptionKey, }; } else { invariant(false, 'Invalid mediaInfo type'); } } } const styles = StyleSheet.create({ container: { flex: 1, }, image: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, }); const ConnectedMultimedia: React.ComponentType = React.memo(function ConnectedMultimedia(props: BaseProps) { const inputState = React.useContext(InputStateContext); return ; }); export default ConnectedMultimedia; diff --git a/native/media/remote-image.react.js b/native/media/remote-image.react.js index 037cfa3bc..7da5980f4 100644 --- a/native/media/remote-image.react.js +++ b/native/media/remote-image.react.js @@ -1,75 +1,75 @@ // @flow import * as React from 'react'; import type { ImageSource } from 'react-native/Libraries/Image/ImageSource'; import { type ConnectionStatus } from 'lib/types/socket-types.js'; import LoadableImage from './loadable-image.react.js'; import { useSelector } from '../redux/redux-utils.js'; import type { ImageStyle } from '../types/styles.js'; type BaseProps = { +uri: string, - +onLoad: (uri: string) => void, + +onLoad?: (uri: string) => void, +spinnerColor: string, +style: ImageStyle, +invisibleLoad: boolean, +placeholder?: ?ImageSource, }; type Props = { ...BaseProps, +connectionStatus: ConnectionStatus, }; type State = { +attempt: number, }; class RemoteImage extends React.PureComponent { loaded: boolean = false; state: State = { attempt: 0, }; componentDidUpdate(prevProps: Props) { if ( !this.loaded && this.props.connectionStatus === 'connected' && prevProps.connectionStatus !== 'connected' ) { this.setState(otherPrevState => ({ attempt: otherPrevState.attempt + 1, })); } } render() { const { style, spinnerColor, invisibleLoad, uri, placeholder } = this.props; const source = { uri }; return ( ); } onLoad = () => { this.loaded = true; this.props.onLoad && this.props.onLoad(this.props.uri); }; } const ConnectedRemoteImage: React.ComponentType = React.memo(function ConnectedRemoteImage(props: BaseProps) { const connectionStatus = useSelector(state => state.connection.status); return ; }); export default ConnectedRemoteImage;