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;