diff --git a/lib/types/media-types.js b/lib/types/media-types.js index 2ee4a3876..b51a52116 100644 --- a/lib/types/media-types.js +++ b/lib/types/media-types.js @@ -1,555 +1,560 @@ // @flow import type { Shape } from './core.js'; import { type Platform } from './device-types.js'; export type Dimensions = $ReadOnly<{ +height: number, +width: number, }>; export type MediaType = 'photo' | 'video'; export type Image = { +id: string, +uri: string, +type: 'photo', +dimensions: Dimensions, // stored on native only during creation in case retry needed after state lost +localMediaSelection?: NativeMediaSelection, }; export type EncryptedImage = { +id: string, // a media URI for keyserver uploads / blob holder for Blob service uploads +holder: string, +encryptionKey: string, +type: 'encrypted_photo', +dimensions: Dimensions, }; export type Video = { +id: string, +uri: string, +type: 'video', +dimensions: Dimensions, +loop?: boolean, +thumbnailID: string, +thumbnailURI: string, // stored on native only during creation in case retry needed after state lost +localMediaSelection?: NativeMediaSelection, }; export type EncryptedVideo = { +id: string, // a media URI for keyserver uploads / blob holder for Blob service uploads +holder: string, +encryptionKey: string, +type: 'encrypted_video', +dimensions: Dimensions, +loop?: boolean, +thumbnailID: string, +thumbnailHolder: string, +thumbnailEncryptionKey: string, }; export type Media = Image | Video | EncryptedImage | EncryptedVideo; +export type AvatarMediaInfo = { + +type: 'photo', + +uri: string, +}; + export type ClientDBMediaInfo = { +id: string, +uri: string, +type: 'photo' | 'video', +extras: string, }; export type Corners = Shape<{ +topLeft: boolean, +topRight: boolean, +bottomLeft: boolean, +bottomRight: boolean, }>; export type MediaInfo = | { ...Image, +index: number, } | { ...Video, +index: number, } | { ...EncryptedImage, +index: number, } | { ...EncryptedVideo, +index: number, }; export type UploadMultimediaResult = { +id: string, +uri: string, +dimensions: Dimensions, +mediaType: MediaType, +loop: boolean, }; export type UpdateMultimediaMessageMediaPayload = { +messageID: string, +currentMediaID: string, +mediaUpdate: Shape, }; export type UploadDeletionRequest = { +id: string, }; export type FFmpegStatistics = { // seconds of video being processed per second +speed: number, // total milliseconds of video processed so far +time: number, // total result file size in bytes so far +size: number, +videoQuality: number, +videoFrameNumber: number, +videoFps: number, +bitrate: number, }; export type TranscodeVideoMediaMissionStep = { +step: 'video_ffmpeg_transcode', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +returnCode: ?number, +newPath: ?string, +stats: ?FFmpegStatistics, }; export type VideoGenerateThumbnailMediaMissionStep = { +step: 'video_generate_thumbnail', +success: boolean, +time: number, // ms +returnCode: number, +thumbnailURI: string, }; export type VideoInfo = { +codec: ?string, +dimensions: ?Dimensions, +duration: number, // seconds +format: $ReadOnlyArray, }; export type VideoProbeMediaMissionStep = { +step: 'video_probe', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +validFormat: boolean, +duration: ?number, // seconds +codec: ?string, +format: ?$ReadOnlyArray, +dimensions: ?Dimensions, }; export type ReadFileHeaderMediaMissionStep = { +step: 'read_file_header', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +mime: ?string, +mediaType: ?MediaType, }; export type DetermineFileTypeMediaMissionStep = { +step: 'determine_file_type', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputFilename: string, +outputMIME: ?string, +outputMediaType: ?MediaType, +outputFilename: ?string, }; export type FrameCountMediaMissionStep = { +step: 'frame_count', +success: boolean, +exceptionMessage: ?string, +time: number, +path: string, +mime: string, +hasMultipleFrames: ?boolean, }; export type DisposeTemporaryFileMediaMissionStep = { +step: 'dispose_temporary_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type MakeDirectoryMediaMissionStep = { +step: 'make_directory', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type AndroidScanFileMediaMissionStep = { +step: 'android_scan_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type FetchFileHashMediaMissionStep = { +step: 'fetch_file_hash', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +hash: ?string, }; export type CopyFileMediaMissionStep = { +step: 'copy_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +source: string, +destination: string, }; export type GetOrientationMediaMissionStep = { +step: 'exif_fetch', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +orientation: ?number, }; export type MediaLibrarySelection = | { +step: 'photo_library', +dimensions: Dimensions, +filename: ?string, +uri: string, +mediaNativeID: ?string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, } | { +step: 'video_library', +dimensions: Dimensions, +filename: ?string, +uri: string, +mediaNativeID: ?string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, +duration: number, // seconds }; export type PhotoCapture = { +step: 'photo_capture', +time: number, // ms +dimensions: Dimensions, +filename: string, +uri: string, +captureTime: number, // ms timestamp +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, }; export type PhotoPaste = { +step: 'photo_paste', +dimensions: Dimensions, +filename: string, +uri: string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, }; export type NativeMediaSelection = | MediaLibrarySelection | PhotoCapture | PhotoPaste; export type MediaMissionStep = | NativeMediaSelection | { +step: 'web_selection', +filename: string, +size: number, // in bytes +mime: string, +selectTime: number, // ms timestamp } | { +step: 'asset_info_fetch', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +localURI: ?string, +orientation: ?number, } | { +step: 'stat_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +fileSize: ?number, } | ReadFileHeaderMediaMissionStep | DetermineFileTypeMediaMissionStep | FrameCountMediaMissionStep | { +step: 'photo_manipulation', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +manipulation: Object, +newMIME: ?string, +newDimensions: ?Dimensions, +newURI: ?string, } | VideoProbeMediaMissionStep | TranscodeVideoMediaMissionStep | VideoGenerateThumbnailMediaMissionStep | DisposeTemporaryFileMediaMissionStep | { +step: 'save_media', +uri: string, +time: number, // ms timestamp } | { +step: 'permissions_check', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +platform: Platform, +permissions: $ReadOnlyArray, } | MakeDirectoryMediaMissionStep | AndroidScanFileMediaMissionStep | { +step: 'ios_save_to_library', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, } | { +step: 'fetch_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputURI: string, +uri: string, +size: ?number, +mime: ?string, } | { +step: 'data_uri_from_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +first255Chars: ?string, } | { +step: 'array_buffer_from_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms } | { +step: 'mime_check', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +mime: ?string, } | { +step: 'write_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +length: number, } | FetchFileHashMediaMissionStep | CopyFileMediaMissionStep | GetOrientationMediaMissionStep | { +step: 'preload_image', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +dimensions: ?Dimensions, } | { +step: 'reorient_image', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: ?string, } | { +step: 'upload', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputFilename: string, +outputMediaType: ?MediaType, +outputURI: ?string, +outputDimensions: ?Dimensions, +outputLoop: ?boolean, +hasWiFi?: boolean, } | { +step: 'wait_for_capture_uri_unload', +success: boolean, +time: number, // ms +uri: string, }; export type MediaMissionFailure = | { +success: false, +reason: 'no_file_path', } | { +success: false, +reason: 'file_stat_failed', +uri: string, } | { +success: false, +reason: 'photo_manipulation_failed', +size: number, // in bytes } | { +success: false, +reason: 'media_type_fetch_failed', +detectedMIME: ?string, } | { +success: false, +reason: 'mime_type_mismatch', +reportedMediaType: MediaType, +reportedMIME: string, +detectedMIME: string, } | { +success: false, +reason: 'http_upload_failed', +exceptionMessage: ?string, } | { +success: false, +reason: 'video_too_long', +duration: number, // in seconds } | { +success: false, +reason: 'video_probe_failed', } | { +success: false, +reason: 'video_transcode_failed', } | { +success: false, +reason: 'video_generate_thumbnail_failed', } | { +success: false, +reason: 'processing_exception', +time: number, // ms +exceptionMessage: ?string, } | { +success: false, +reason: 'save_unsupported', } | { +success: false, +reason: 'missing_permission', } | { +success: false, +reason: 'make_directory_failed', } | { +success: false, +reason: 'resolve_failed', +uri: string, } | { +success: false, +reason: 'save_to_library_failed', +uri: string, } | { +success: false, +reason: 'fetch_failed', } | { +success: false, +reason: 'data_uri_failed', } | { +success: false, +reason: 'array_buffer_failed', } | { +success: false, +reason: 'mime_check_failed', +mime: ?string, } | { +success: false, +reason: 'write_file_failed', } | { +success: false, +reason: 'fetch_file_hash_failed', } | { +success: false, +reason: 'copy_file_failed', } | { +success: false, +reason: 'exif_fetch_failed', } | { +success: false, +reason: 'reorient_image_failed', } | { +success: false, +reason: 'web_sibling_validation_failed', }; export type MediaMissionResult = MediaMissionFailure | { +success: true }; export type MediaMission = { +steps: $ReadOnlyArray, +result: MediaMissionResult, +userTime: number, +totalTime: number, }; diff --git a/native/components/avatar.react.js b/native/components/avatar.react.js index 6dc448db1..29730913c 100644 --- a/native/components/avatar.react.js +++ b/native/components/avatar.react.js @@ -1,106 +1,130 @@ // @flow import * as React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import type { ClientAvatar } from 'lib/types/avatar-types.js'; +import Multimedia from '../media/multimedia.react.js'; import { useShouldRenderAvatars } from '../utils/avatar-utils.js'; type Props = { +avatarInfo: ClientAvatar, +size?: 'large' | 'small' | 'profile' | 'micro', }; function Avatar(props: Props): React.Node { const { avatarInfo, size } = props; const shouldRenderAvatars = useShouldRenderAvatars(); const containerSizeStyle = React.useMemo(() => { if (size === 'profile') { return styles.profile; } else if (size === 'small') { return styles.small; } else if (size === 'micro') { return styles.micro; } return styles.large; }, [size]); const emojiContainerStyle = React.useMemo(() => { const containerStyles = [styles.emojiContainer, containerSizeStyle]; if (avatarInfo.type === 'emoji') { const backgroundColor = { backgroundColor: `#${avatarInfo.color}` }; containerStyles.push(backgroundColor); } return containerStyles; }, [avatarInfo, containerSizeStyle]); const emojiSizeStyle = React.useMemo(() => { if (size === 'profile') { return styles.emojiProfile; } else if (size === 'small') { return styles.emojiSmall; } else if (size === 'micro') { return styles.emojiMicro; } return styles.emojiLarge; }, [size]); - if (!shouldRenderAvatars) { - return null; - } + const avatar = React.useMemo(() => { + if (avatarInfo.type === 'image') { + const avatarMediaInfo = { + type: 'photo', + uri: avatarInfo.uri, + }; - return ( - - {avatarInfo.emoji} - - ); + return ( + + + + ); + } + + return ( + + {avatarInfo.emoji} + + ); + }, [ + avatarInfo.emoji, + avatarInfo.type, + avatarInfo.uri, + containerSizeStyle, + emojiContainerStyle, + emojiSizeStyle, + ]); + + return shouldRenderAvatars ? avatar : null; } const styles = StyleSheet.create({ emojiContainer: { alignItems: 'center', justifyContent: 'center', }, emojiLarge: { fontSize: 28, textAlign: 'center', }, emojiMicro: { fontSize: 9, textAlign: 'center', }, emojiProfile: { fontSize: 80, textAlign: 'center', }, emojiSmall: { fontSize: 14, textAlign: 'center', }, + imageContainer: { + overflow: 'hidden', + }, large: { borderRadius: 20, height: 40, width: 40, }, micro: { borderRadius: 8, height: 16, width: 16, }, profile: { borderRadius: 56, height: 112, width: 112, }, small: { borderRadius: 12, height: 24, width: 24, }, }); export default Avatar; diff --git a/native/media/multimedia.react.js b/native/media/multimedia.react.js index ef8c99aac..4adec1849 100644 --- a/native/media/multimedia.react.js +++ b/native/media/multimedia.react.js @@ -1,163 +1,163 @@ // @flow import { Image } from 'expo-image'; import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; -import { type MediaInfo } from 'lib/types/media-types.js'; +import type { MediaInfo, AvatarMediaInfo } from 'lib/types/media-types.js'; import RemoteImage from './remote-image.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; type BaseProps = { - +mediaInfo: MediaInfo, + +mediaInfo: MediaInfo | AvatarMediaInfo, +spinnerColor: string, }; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, }; type State = { +currentURI: string, +departingURI: ?string, }; class Multimedia extends React.PureComponent { static defaultProps = { spinnerColor: 'black', }; constructor(props: Props) { super(props); invariant( props.mediaInfo.type === 'image' || props.mediaInfo.type === 'video', ' supports only unencrypted images and videos', ); this.state = { currentURI: props.mediaInfo.type === 'video' ? props.mediaInfo.thumbnailURI : props.mediaInfo.uri, departingURI: null, }; } get inputState() { const { inputState } = this.props; invariant(inputState, 'inputState should be set in Multimedia'); return inputState; } componentDidMount() { this.inputState.reportURIDisplayed(this.state.currentURI, true); } componentWillUnmount() { const { inputState } = this; const { currentURI, departingURI } = this.state; inputState.reportURIDisplayed(currentURI, false); if (departingURI) { inputState.reportURIDisplayed(departingURI, false); } } componentDidUpdate(prevProps: Props, prevState: State) { const { inputState } = this; invariant( this.props.mediaInfo.type === 'image' || this.props.mediaInfo.type === 'video', ' supports only unencrypted images and videos', ); const newURI = this.props.mediaInfo.type === 'video' ? this.props.mediaInfo.thumbnailURI : this.props.mediaInfo.uri; const oldURI = this.state.currentURI; if (newURI !== oldURI) { inputState.reportURIDisplayed(newURI, true); const { departingURI } = this.state; if (departingURI && oldURI !== departingURI) { // If there's currently an existing departingURI, that means that oldURI // hasn't loaded yet. Since it's being replaced anyways we don't need to // display it anymore, so we can unlink it now inputState.reportURIDisplayed(oldURI, false); this.setState({ currentURI: newURI }); } else { this.setState({ currentURI: newURI, departingURI: oldURI }); } } const newDepartingURI = this.state.departingURI; const oldDepartingURI = prevState.departingURI; if (oldDepartingURI && oldDepartingURI !== newDepartingURI) { inputState.reportURIDisplayed(oldDepartingURI, false); } } render() { const images = []; const { currentURI, departingURI } = this.state; if (departingURI) { images.push(this.renderURI(currentURI, true)); images.push(this.renderURI(departingURI, true)); } else { images.push(this.renderURI(currentURI)); } return {images}; } renderURI(uri: string, invisibleLoad?: boolean = false) { if (uri.startsWith('http')) { return ( ); } else { const source = { uri }; return ( ); } } onLoad = () => { this.setState({ departingURI: null }); }; } 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;