diff --git a/lib/media/file-utils.js b/lib/media/file-utils.js index a75baa9f7..bedeb8523 100644 --- a/lib/media/file-utils.js +++ b/lib/media/file-utils.js @@ -1,220 +1,218 @@ // @flow import fileType from 'file-type'; import invariant from 'invariant'; import type { Shape } from '../types/core'; import type { MediaType } from '../types/media-types'; type ResultMIME = 'image/png' | 'image/jpeg'; type MediaConfig = { +mediaType: 'photo' | 'video' | 'photo_or_video', +extension: string, +serverCanHandle: boolean, +serverTranscodesImage: boolean, +imageConfig?: Shape<{ +convertTo: ResultMIME, }>, +videoConfig?: Shape<{ +loop: boolean, }>, }; const mediaConfig: { [mime: string]: MediaConfig } = Object.freeze({ 'image/png': { mediaType: 'photo', extension: 'png', serverCanHandle: true, serverTranscodesImage: true, }, 'image/jpeg': { mediaType: 'photo', extension: 'jpg', serverCanHandle: true, serverTranscodesImage: true, }, 'image/gif': { // Set mediaType to 'photo_or_video' when working on // video messages to treat animated GIFs as videos. mediaType: 'photo', extension: 'gif', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/png', }, videoConfig: { loop: true, }, }, 'image/heic': { mediaType: 'photo', extension: 'heic', serverCanHandle: false, serverTranscodesImage: false, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/webp': { mediaType: 'photo', extension: 'webp', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/tiff': { mediaType: 'photo', extension: 'tiff', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/svg+xml': { mediaType: 'photo', extension: 'svg', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/png', }, }, 'image/bmp': { mediaType: 'photo', extension: 'bmp', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/png', }, }, 'video/mp4': { mediaType: 'video', extension: 'mp4', - // serverCanHandle set to false pending future video message progress - serverCanHandle: false, + serverCanHandle: true, serverTranscodesImage: false, }, 'video/quicktime': { mediaType: 'video', extension: 'mp4', - // serverCanHandle set to false pending future video message progress - serverCanHandle: false, + serverCanHandle: true, serverTranscodesImage: false, }, }); const serverTranscodableTypes: Set<$Keys> = new Set(); const serverCanHandleTypes: Set<$Keys> = new Set(); for (const mime in mediaConfig) { if (mediaConfig[mime].serverTranscodesImage) { serverTranscodableTypes.add(mime); } if (mediaConfig[mime].serverCanHandle) { serverCanHandleTypes.add(mime); } } function getTargetMIME(inputMIME: string): ResultMIME { const config = mediaConfig[inputMIME]; if (!config) { return 'image/jpeg'; } const targetMIME = config.imageConfig && config.imageConfig.convertTo; if (targetMIME) { return targetMIME; } invariant( inputMIME === 'image/png' || inputMIME === 'image/jpeg', 'all images must be converted to jpeg or png', ); return inputMIME; } const bytesNeededForFileTypeCheck = 64; export type FileDataInfo = { mime: ?string, mediaType: ?MediaType, }; function fileInfoFromData( data: Uint8Array | Buffer | ArrayBuffer, ): FileDataInfo { const fileTypeResult = fileType(data); if (!fileTypeResult) { return { mime: null, mediaType: null }; } const { mime } = fileTypeResult; const rawMediaType = mediaConfig[mime] && mediaConfig[mime].mediaType; const mediaType = rawMediaType === 'photo_or_video' ? 'photo' : rawMediaType; return { mime, mediaType }; } function replaceExtension(filename: string, ext: string): string { const lastIndex = filename.lastIndexOf('.'); let name = lastIndex >= 0 ? filename.substring(0, lastIndex) : filename; if (!name) { name = Math.random().toString(36).slice(-5); } const maxReadableLength = 255 - ext.length - 1; return `${name.substring(0, maxReadableLength)}.${ext}`; } function readableFilename(filename: string, mime: string): ?string { const ext = mediaConfig[mime] && mediaConfig[mime].extension; if (!ext) { return null; } return replaceExtension(filename, ext); } const extRegex = /\.([0-9a-z]+)$/i; function extensionFromFilename(filename: string): ?string { const matches = filename.match(extRegex); if (!matches) { return null; } const match = matches[1]; if (!match) { return null; } return match.toLowerCase(); } const pathRegex = /^file:\/\/(.*)$/; function pathFromURI(uri: string): ?string { const matches = uri.match(pathRegex); if (!matches) { return null; } return matches[1] ? matches[1] : null; } const filenameRegex = /[^/]+$/; function filenameFromPathOrURI(pathOrURI: string): ?string { const matches = pathOrURI.match(filenameRegex); if (!matches) { return null; } return matches[0] ? matches[0] : null; } export { mediaConfig, serverTranscodableTypes, serverCanHandleTypes, getTargetMIME, bytesNeededForFileTypeCheck, fileInfoFromData, replaceExtension, readableFilename, extensionFromFilename, pathFromURI, filenameFromPathOrURI, }; diff --git a/lib/shared/messages/multimedia-message-spec.js b/lib/shared/messages/multimedia-message-spec.js index f7be5d2fc..b65586f12 100644 --- a/lib/shared/messages/multimedia-message-spec.js +++ b/lib/shared/messages/multimedia-message-spec.js @@ -1,380 +1,380 @@ // @flow import invariant from 'invariant'; import { contentStringForMediaArray, multimediaMessagePreview, shimUploadURI, } from '../../media/media-utils'; import type { PlatformDetails } from '../../types/device-types'; import type { Media, Video, Image } from '../../types/media-types'; import { messageTypes, assertMessageType, isMediaMessageType, } from '../../types/message-types'; import type { MessageInfo, RawMessageInfo, RawMultimediaMessageInfo, MultimediaMessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { ImagesMessageData, RawImagesMessageInfo, ImagesMessageInfo, } from '../../types/messages/images'; import type { MediaMessageData, MediaMessageInfo, RawMediaMessageInfo, } from '../../types/messages/media'; import { getMediaMessageServerDBContentsFromMedia } from '../../types/messages/media'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { translateClientDBMediaInfosToMedia, translateClientDBMediaInfoToImage, } from '../../utils/message-ops-utils'; import { createMediaMessageInfo, messagePreviewText, removeCreatorAsViewer, } from '../message-utils'; import { threadIsGroupChat } from '../thread-utils'; import { stringForUser } from '../user-utils'; import { hasMinCodeVersion } from '../version-utils'; import type { MessageSpec, MessageTitleParam, NotificationTextsParams, RawMessageInfoFromServerDBRowParams, } from './message-spec'; import { joinResult } from './utils'; export const multimediaMessageSpec: MessageSpec< MediaMessageData | ImagesMessageData, RawMediaMessageInfo | RawImagesMessageInfo, MediaMessageInfo | ImagesMessageInfo, > = Object.freeze({ messageContentForServerDB( data: | MediaMessageData | ImagesMessageData | RawMediaMessageInfo | RawImagesMessageInfo, ): string { if (data.type === messageTypes.MULTIMEDIA) { return JSON.stringify( getMediaMessageServerDBContentsFromMedia(data.media), ); } const mediaIDs = data.media.map(media => parseInt(media.id, 10)); return JSON.stringify(mediaIDs); }, messageContentForClientDB( data: RawMediaMessageInfo | RawImagesMessageInfo, ): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawImagesMessageInfo | RawMediaMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( isMediaMessageType(messageType), 'message must be of type IMAGES or MULTIMEDIA', ); invariant( clientDBMessageInfo.media_infos !== null && clientDBMessageInfo.media_infos !== undefined, `media_infos must be defined`, ); const rawMessageInfo: RawImagesMessageInfo | RawMediaMessageInfo = messageType === messageTypes.IMAGES ? { type: messageTypes.IMAGES, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: clientDBMessageInfo.media_infos?.map( translateClientDBMediaInfoToImage, ) ?? [], } : { type: messageTypes.MULTIMEDIA, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: translateClientDBMediaInfosToMedia(clientDBMessageInfo), }; if (clientDBMessageInfo.local_id) { rawMessageInfo.localID = clientDBMessageInfo.local_id; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawMessageInfo.id = clientDBMessageInfo.id; } return rawMessageInfo; }, messageTitle({ messageInfo, threadInfo, viewerContext, }: MessageTitleParam) { let validMessageInfo: MultimediaMessageInfo = (messageInfo: MultimediaMessageInfo); if (viewerContext === 'global_viewer') { validMessageInfo = removeCreatorAsViewer(validMessageInfo); } return messagePreviewText(validMessageInfo, threadInfo); }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawMediaMessageInfo | RawImagesMessageInfo { const { localID, media } = params; invariant(media, 'Media should be provided'); return createMediaMessageInfo({ threadID: row.threadID.toString(), creatorID: row.creatorID.toString(), media, id: row.id.toString(), localID, time: row.time, }); }, createMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, creator: RelativeUserInfo, ): ?(MediaMessageInfo | ImagesMessageInfo) { if (rawMessageInfo.type === messageTypes.IMAGES) { const messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } }, rawMessageInfoFromMessageData( messageData: MediaMessageData | ImagesMessageData, id: ?string, ): RawMediaMessageInfo | RawImagesMessageInfo { if (messageData.type === messageTypes.IMAGES && id) { return ({ ...messageData, id }: RawImagesMessageInfo); } else if (messageData.type === messageTypes.IMAGES) { return ({ ...messageData }: RawImagesMessageInfo); } else if (id) { return ({ ...messageData, id }: RawMediaMessageInfo); } else { return ({ ...messageData }: RawMediaMessageInfo); } }, shimUnsupportedMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, platformDetails: ?PlatformDetails, ): RawMediaMessageInfo | RawImagesMessageInfo | RawUnsupportedMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); return shimmedRawMessageInfo; } else { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); // TODO figure out first native codeVersion supporting video playback - if (hasMinCodeVersion(platformDetails, 99999)) { + if (hasMinCodeVersion(platformDetails, 158)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } }, unshimMessageInfo( unwrapped: RawMediaMessageInfo | RawImagesMessageInfo, messageInfo: RawMessageInfo, ): ?RawMessageInfo { if (unwrapped.type === messageTypes.IMAGES) { return { ...unwrapped, media: unwrapped.media.map(media => { if (media.dimensions) { return media; } const dimensions = preDimensionUploads[media.id]; invariant( dimensions, 'only four photos were uploaded before dimensions were calculated, ' + `and ${media.id} was not one of them`, ); return { ...media, dimensions }; }), }; } else if (unwrapped.type === messageTypes.MULTIMEDIA) { for (const { type } of unwrapped.media) { if (type !== 'photo' && type !== 'video') { return messageInfo; } } } return undefined; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const media = []; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, 'messageInfo should be multimedia type!', ); for (const singleMedia of messageInfo.media) { media.push(singleMedia); } } const contentString = contentStringForMediaArray(media); const userString = stringForUser(messageInfos[0].creator); let body, merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { body = `sent you ${contentString}`; merged = body; } else { body = `sent ${contentString}`; const threadName = params.notifThreadName(threadInfo); merged = `${body} to ${threadName}`; } merged = `${userString} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: userString, }; }, notificationCollapseKey( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, ): string { // We use the legacy constant here to collapse both types into one return joinResult( messageTypes.IMAGES, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, generatesNotifs: true, includedInRepliesCount: true, }); function shimMediaMessageInfo( rawMessageInfo: RawMultimediaMessageInfo, platformDetails: ?PlatformDetails, ): RawMultimediaMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { let uriChanged = false; const newMedia: Image[] = []; for (const singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawImagesMessageInfo); } else { let uriChanged = false; const newMedia: Media[] = []; for (const singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else if (singleMedia.type === 'photo') { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Video)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawMediaMessageInfo); } } // Four photos were uploaded before dimensions were calculated server-side, // and delivered to clients without dimensions in the MultimediaMessageInfo. const preDimensionUploads = { '156642': { width: 1440, height: 1080 }, '156649': { width: 720, height: 803 }, '156794': { width: 720, height: 803 }, '156877': { width: 574, height: 454 }, }; diff --git a/lib/shared/unshim-utils.js b/lib/shared/unshim-utils.js index 61a2bc11f..4b3752893 100644 --- a/lib/shared/unshim-utils.js +++ b/lib/shared/unshim-utils.js @@ -1,54 +1,55 @@ // @flow import _mapValues from 'lodash/fp/mapValues'; import { type MessageStore, type RawMessageInfo, type MessageType, messageTypes, } from '../types/message-types'; import { messageSpecs } from './messages/message-specs'; function unshimFunc( messageInfo: RawMessageInfo, unshimTypes: Set, ): RawMessageInfo { if (messageInfo.type !== messageTypes.UNSUPPORTED) { return messageInfo; } if (!unshimTypes.has(messageInfo.unsupportedMessageInfo.type)) { return messageInfo; } const unwrapped = messageInfo.unsupportedMessageInfo; const { unshimMessageInfo } = messageSpecs[unwrapped.type]; const unshimmed = unshimMessageInfo?.(unwrapped, messageInfo); return unshimmed ?? unwrapped; } function unshimMessageStore( messageStore: MessageStore, unshimTypes: $ReadOnlyArray, ): MessageStore { const set = new Set(unshimTypes); const messages = _mapValues((messageInfo: RawMessageInfo) => unshimFunc(messageInfo, set), )(messageStore.messages); return { ...messageStore, messages }; } const localUnshimTypes = new Set([ messageTypes.IMAGES, messageTypes.UPDATE_RELATIONSHIP, messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, + messageTypes.MULTIMEDIA, ]); function unshimMessageInfos( messageInfos: $ReadOnlyArray, ): RawMessageInfo[] { return messageInfos.map((messageInfo: RawMessageInfo) => unshimFunc(messageInfo, localUnshimTypes), ); } export { unshimMessageStore, unshimMessageInfos, unshimFunc }; diff --git a/native/media/media-gallery-keyboard.react.js b/native/media/media-gallery-keyboard.react.js index 6015b5088..18c080b5c 100644 --- a/native/media/media-gallery-keyboard.react.js +++ b/native/media/media-gallery-keyboard.react.js @@ -1,557 +1,555 @@ // @flow import * as MediaLibrary from 'expo-media-library'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text, FlatList, ActivityIndicator, Animated, Easing, Platform, } from 'react-native'; import { KeyboardRegistry } from 'react-native-keyboard-input'; import { Provider } from 'react-redux'; import { extensionFromFilename } from 'lib/media/file-utils'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils'; import type { MediaLibrarySelection } from 'lib/types/media-types'; import type { DimensionsInfo } from '../redux/dimensions-updater.react'; import { store } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import type { LayoutEvent, ViewableItemsChange } from '../types/react-native'; import type { ViewStyle } from '../types/styles'; import { getCompatibleMediaURI } from './identifier-utils'; import MediaGalleryMedia from './media-gallery-media.react'; import SendMediaButton from './send-media-button.react'; const animationSpec = { duration: 400, // $FlowFixMe[method-unbinding] easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; type Props = { // Redux state +dimensions: DimensionsInfo, +foreground: boolean, +colors: Colors, +styles: typeof unboundStyles, }; type State = { +selections: ?$ReadOnlyArray, +error: ?string, +containerHeight: ?number, // null means end reached; undefined means no fetch yet +cursor: ?string, +queuedMediaURIs: ?Set, +focusedMediaURI: ?string, +dimensions: DimensionsInfo, }; class MediaGalleryKeyboard extends React.PureComponent { mounted = false; fetchingPhotos = false; flatList: ?FlatList; viewableIndices: number[] = []; queueModeProgress = new Animated.Value(0); sendButtonStyle: ViewStyle; mediaSelected = false; constructor(props: Props) { super(props); const sendButtonScale = this.queueModeProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.3, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.queueModeProgress, transform: [{ scale: sendButtonScale }], }; this.state = { selections: null, error: null, containerHeight: null, cursor: undefined, queuedMediaURIs: null, focusedMediaURI: null, dimensions: props.dimensions, }; } static getDerivedStateFromProps(props: Props) { // We keep this in state since we pass this.state as // FlatList's extraData prop return { dimensions: props.dimensions }; } componentDidMount() { this.mounted = true; return this.fetchPhotos(); } componentWillUnmount() { this.mounted = false; } componentDidUpdate(prevProps: Props, prevState: State) { const { queuedMediaURIs } = this.state; const prevQueuedMediaURIs = prevState.queuedMediaURIs; if (queuedMediaURIs && !prevQueuedMediaURIs) { Animated.timing(this.queueModeProgress, { ...animationSpec, toValue: 1, }).start(); } else if (!queuedMediaURIs && prevQueuedMediaURIs) { Animated.timing(this.queueModeProgress, { ...animationSpec, toValue: 0, }).start(); } const { flatList, viewableIndices } = this; const { selections, focusedMediaURI } = this.state; let scrollingSomewhere = false; if (flatList && selections) { let newURI; if (focusedMediaURI && focusedMediaURI !== prevState.focusedMediaURI) { newURI = focusedMediaURI; } else if ( queuedMediaURIs && (!prevQueuedMediaURIs || queuedMediaURIs.size > prevQueuedMediaURIs.size) ) { const flowMadeMeDoThis = queuedMediaURIs; for (const queuedMediaURI of flowMadeMeDoThis) { if (prevQueuedMediaURIs && prevQueuedMediaURIs.has(queuedMediaURI)) { continue; } newURI = queuedMediaURI; break; } } let index; if (newURI !== null && newURI !== undefined) { index = selections.findIndex(({ uri }) => uri === newURI); } if (index !== null && index !== undefined) { if (index === viewableIndices[0]) { scrollingSomewhere = true; flatList.scrollToIndex({ index }); } else if (index === viewableIndices[viewableIndices.length - 1]) { scrollingSomewhere = true; flatList.scrollToIndex({ index, viewPosition: 1 }); } } } if (this.props.foreground && !prevProps.foreground) { this.fetchPhotos(); } if ( !scrollingSomewhere && this.flatList && this.state.selections && prevState.selections && this.state.selections.length > 0 && prevState.selections.length > 0 && this.state.selections[0].uri !== prevState.selections[0].uri ) { this.flatList.scrollToIndex({ index: 0 }); } } guardedSetState(change) { if (this.mounted) { this.setState(change); } } async fetchPhotos(after?: ?string) { if (this.fetchingPhotos) { return; } this.fetchingPhotos = true; try { const hasPermission = await this.getPermissions(); if (!hasPermission) { return; } const { assets, endCursor, hasNextPage, } = await MediaLibrary.getAssetsAsync({ first: 20, after, - // Include `MediaLibrary.MediaType.video` in `mediaType` array - // when working on video messages so videos show up in media gallery. - mediaType: [MediaLibrary.MediaType.photo], + mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video], }); let firstRemoved = false, lastRemoved = false; const mediaURIs = this.state.selections ? this.state.selections.map(({ uri }) => uri) : []; const existingURIs = new Set(mediaURIs); let first = true; const selections = assets .map(asset => { const { id, height, width, filename, mediaType, duration } = asset; const isVideo = mediaType === MediaLibrary.MediaType.video; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(filename), ); if (existingURIs.has(uri)) { if (first) { firstRemoved = true; } lastRemoved = true; first = false; return null; } first = false; lastRemoved = false; existingURIs.add(uri); if (isVideo) { return { step: 'video_library', dimensions: { height, width }, uri, filename, mediaNativeID: id, duration, selectTime: 0, sendTime: 0, retries: 0, }; } else { return { step: 'photo_library', dimensions: { height, width }, uri, filename, mediaNativeID: id, selectTime: 0, sendTime: 0, retries: 0, }; } }) .filter(Boolean); let appendOrPrepend = after ? 'append' : 'prepend'; if (firstRemoved && !lastRemoved) { appendOrPrepend = 'append'; } else if (!firstRemoved && lastRemoved) { appendOrPrepend = 'prepend'; } let newSelections = selections; if (this.state.selections) { if (appendOrPrepend === 'prepend') { newSelections = [...newSelections, ...this.state.selections]; } else { newSelections = [...this.state.selections, ...newSelections]; } } this.guardedSetState({ selections: newSelections, error: null, cursor: hasNextPage ? endCursor : null, }); } catch (e) { this.guardedSetState({ selections: null, error: 'something went wrong :(', }); } this.fetchingPhotos = false; } async getPermissions(): Promise { const { granted } = await MediaLibrary.requestPermissionsAsync(); if (!granted) { this.guardedSetState({ error: "don't have permission :(" }); } return granted; } get queueModeActive() { return !!this.state.queuedMediaURIs; } renderItem = (row: { item: MediaLibrarySelection, ... }) => { const { containerHeight, queuedMediaURIs } = this.state; invariant(containerHeight, 'should be set'); const { uri } = row.item; const isQueued = !!(queuedMediaURIs && queuedMediaURIs.has(uri)); const { queueModeActive } = this; return ( ); }; ItemSeparator = () => { return ; }; static keyExtractor = (item: MediaLibrarySelection) => { return item.uri; }; render() { let content; const { selections, error, containerHeight } = this.state; const bottomOffsetStyle: ViewStyle = { marginBottom: this.props.dimensions.bottomInset, }; if (selections && selections.length > 0 && containerHeight) { content = ( ); } else if (selections && containerHeight) { content = ( no media was found! ); } else if (error) { content = ( {error} ); } else { content = ( ); } const { queuedMediaURIs } = this.state; const queueCount = queuedMediaURIs ? queuedMediaURIs.size : 0; const bottomInset = Platform.select({ ios: -1 * this.props.dimensions.bottomInset, default: 0, }); const containerStyle = { bottom: bottomInset }; return ( {content} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onContainerLayout = (event: LayoutEvent) => { this.guardedSetState({ containerHeight: event.nativeEvent.layout.height }); }; onEndReached = () => { const { cursor } = this.state; if (cursor !== null) { this.fetchPhotos(cursor); } }; onViewableItemsChanged = (info: ViewableItemsChange) => { const viewableIndices = []; for (const { index } of info.viewableItems) { if (index !== null && index !== undefined) { viewableIndices.push(index); } } this.viewableIndices = viewableIndices; }; setMediaQueued = (selection: MediaLibrarySelection, isQueued: boolean) => { this.setState((prevState: State) => { const prevQueuedMediaURIs = prevState.queuedMediaURIs ? [...prevState.queuedMediaURIs] : []; if (isQueued) { return { queuedMediaURIs: new Set([...prevQueuedMediaURIs, selection.uri]), focusedMediaURI: null, }; } const queuedMediaURIs = prevQueuedMediaURIs.filter( uri => uri !== selection.uri, ); if (queuedMediaURIs.length < prevQueuedMediaURIs.length) { return { queuedMediaURIs: new Set(queuedMediaURIs), focusedMediaURI: null, }; } return null; }); }; setFocus = (selection: MediaLibrarySelection, isFocused: boolean) => { const { uri } = selection; if (isFocused) { this.setState({ focusedMediaURI: uri }); } else if (this.state.focusedMediaURI === uri) { this.setState({ focusedMediaURI: null }); } }; sendSingleMedia = (selection: MediaLibrarySelection) => { this.sendMedia([selection]); }; sendQueuedMedia = () => { const { selections, queuedMediaURIs } = this.state; if (!selections || !queuedMediaURIs) { return; } const queuedSelections = []; for (const uri of queuedMediaURIs) { for (const selection of selections) { if (selection.uri === uri) { queuedSelections.push(selection); break; } } } this.sendMedia(queuedSelections); }; sendMedia(selections: $ReadOnlyArray) { if (this.mediaSelected) { return; } this.mediaSelected = true; const now = Date.now(); const timeProps = { selectTime: now, sendTime: now, }; const selectionsWithTime = selections.map(selection => ({ ...selection, ...timeProps, })); KeyboardRegistry.onItemSelected( mediaGalleryKeyboardName, selectionsWithTime, ); } } const mediaGalleryKeyboardName = 'MediaGalleryKeyboard'; const unboundStyles = { container: { alignItems: 'center', backgroundColor: 'listBackground', flexDirection: 'row', left: 0, position: 'absolute', right: 0, top: 0, }, error: { color: 'listBackgroundLabel', flex: 1, fontSize: 28, textAlign: 'center', }, loadingIndicator: { flex: 1, }, sendButtonContainer: { bottom: 20, position: 'absolute', right: 30, }, separator: { width: 2, }, }; function ConnectedMediaGalleryKeyboard() { const dimensions = useSelector(state => state.dimensions); const foreground = useIsAppForegrounded(); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); } function ReduxMediaGalleryKeyboard() { return ( ); } KeyboardRegistry.registerKeyboard( mediaGalleryKeyboardName, () => ReduxMediaGalleryKeyboard, ); export { mediaGalleryKeyboardName }; diff --git a/native/redux/persist.js b/native/redux/persist.js index ac70b8479..1a2423df4 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,515 +1,515 @@ // @flow import AsyncStorage from '@react-native-async-storage/async-storage'; import invariant from 'invariant'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createMigrate, createTransform } from 'redux-persist'; import type { Transform } from 'redux-persist/es/types'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils'; import { getContainingThreadID, getCommunity } from 'lib/shared/thread-utils'; import { unshimMessageStore, unshimFunc } from 'lib/shared/unshim-utils'; import { defaultEnabledApps } from 'lib/types/enabled-apps'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import { type LocalMessageInfo, type MessageStore, messageTypes, type ClientDBMessageStoreOperation, } from 'lib/types/message-types'; import type { ClientDBMessageInfo } from 'lib/types/message-types'; import { defaultConnectionInfo } from 'lib/types/socket-types'; import { translateClientDBMessageInfoToRawMessageInfo, translateRawMessageInfoToClientDBMessageInfo, } from 'lib/utils/message-ops-utils'; import { convertThreadStoreOperationsToClientDBOperations } from 'lib/utils/thread-ops-utils'; import { commCoreModule } from '../native-modules'; import { defaultNotifPermissionAlertInfo } from '../push/alerts'; import { defaultDeviceCameraInfo } from '../types/camera'; import { defaultGlobalThemeInfo } from '../types/themes'; import { isTaskCancelledError } from '../utils/error-handling'; import { migrateThreadStoreForEditThreadPermissions } from './edit-thread-permission-migration'; import type { AppState } from './state-types'; const migrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: state => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, threadIDsToNotifIDs: state.threadIDsToNotifIDs, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: state => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: state => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: (state: AppState) => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: (state: AppState) => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [messageTypes.IMAGES]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => state, [15]: state => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: state => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, [17]: state => ({ ...state, userInfos: undefined, userStore: { userInfos: state.userInfos, inconsistencyResponses: [], }, }), [18]: state => ({ ...state, userStore: { userInfos: state.userStore.userInfos, inconsistencyReports: [], }, }), [19]: state => { const threadInfos = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const { visibilityRules, ...rest } = threadInfo; threadInfos[threadID] = rest; } return { ...state, threadStore: { ...state.threadStore, threadInfos, }, }; }, [20]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.UPDATE_RELATIONSHIP, ]), }), [21]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, ]), }), [22]: state => { for (const key in state.drafts) { const value = state.drafts[key]; try { commCoreModule.updateDraft({ key, text: value, }); } catch (e) { if (!isTaskCancelledError(e)) { throw e; } } } return { ...state, drafts: undefined, }; }, [23]: state => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [24]: state => ({ ...state, enabledApps: defaultEnabledApps, }), [25]: state => ({ ...state, crashReportsEnabled: __DEV__, }), [26]: state => { const { currentUserInfo } = state; if (currentUserInfo.anonymous) { return state; } return { ...state, crashReportsEnabled: undefined, currentUserInfo: { id: currentUserInfo.id, username: currentUserInfo.username, }, enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, }; }, [27]: state => ({ ...state, queuedReports: undefined, enabledReports: undefined, threadStore: { ...state.threadStore, inconsistencyReports: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: undefined, }, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [ ...state.entryStore.inconsistencyReports, ...state.threadStore.inconsistencyReports, ...state.queuedReports, ], }, }), [28]: state => { const threadParentToChildren = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? state.threadStore.threadInfos[threadInfo.parentThreadID] : null; const parentIndex = parentThreadInfo ? parentThreadInfo.id : '-1'; if (!threadParentToChildren[parentIndex]) { threadParentToChildren[parentIndex] = []; } threadParentToChildren[parentIndex].push(threadID); } const rootIDs = threadParentToChildren['-1']; if (!rootIDs) { // This should never happen, but if it somehow does we'll let the state // check mechanism resolve it... return state; } const threadInfos = {}; const stack = [...rootIDs]; while (stack.length > 0) { const threadID = stack.shift(); const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; threadInfos[threadID] = { ...threadInfo, containingThreadID: getContainingThreadID( parentThreadInfo, threadInfo.type, ), community: getCommunity(parentThreadInfo), }; const children = threadParentToChildren[threadID]; if (children) { stack.push(...children); } } return { ...state, threadStore: { ...state.threadStore, threadInfos } }; }, [29]: (state: AppState) => { const updatedThreadInfos = migrateThreadStoreForEditThreadPermissions( state.threadStore.threadInfos, ); return { ...state, threadStore: { ...state.threadStore, threadInfos: updatedThreadInfos, }, }; }, [30]: (state: AppState) => { const threadInfos = state.threadStore.threadInfos; const operations = [ { type: 'remove_all', }, ...Object.keys(threadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: threadInfos[id] }, })), ]; try { commCoreModule.processThreadStoreOperationsSync( convertThreadStoreOperationsToClientDBOperations(operations), ); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [31]: (state: AppState) => { const messages = state.messageStore.messages; const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...Object.keys(messages).map((id: string) => ({ type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo(messages[id]), })), ]; try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [32]: (state: AppState) => { // 1. Get messages from SQLite `messages` table. const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); // 2. Translate `ClientDBMessageInfo`s to `RawMessageInfo`s. const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); // 3. "Unshim" translated `RawMessageInfo`s. const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => unshimFunc(messageInfo, new Set([messageTypes.MULTIMEDIA])), ); // 4. Translate unshimmed `RawMessageInfo`s back to `ClientDBMessageInfo`s. const unshimmedClientDBMessageInfos = unshimmedRawMessageInfos.map( translateRawMessageInfoToClientDBMessageInfo, ); // 5. Construct `ClientDBMessageStoreOperation`s to clear SQLite `messages` // table and repopulate with unshimmed `ClientDBMessageInfo`s. const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...unshimmedClientDBMessageInfos.map((message: ClientDBMessageInfo) => ({ type: 'replace', payload: message, })), ]; // 6. Try processing `ClientDBMessageStoreOperation`s and log out if // `processMessageStoreOperationsSync(...)` throws an exception. try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return state; }, }; // After migration 31, we'll no longer want to persist `messageStore.messages` // via redux-persist. However, we DO want to continue persisting everything in // `messageStore` EXCEPT for `messages`. The `blacklist` property in // `persistConfig` allows us to specify top-level keys that shouldn't be // persisted. However, we aren't able to specify nested keys in `blacklist`. // As a result, if we want to prevent nested keys from being persisted we'll // need to use `createTransform(...)` to specify an `inbound` function that // allows us to modify the `state` object before it's passed through // `JSON.stringify(...)` and written to disk. We specify the keys for which // this transformation should be executed in the `whitelist` property of the // `config` object that's passed to `createTransform(...)`. // eslint-disable-next-line no-unused-vars type PersistedThreadMessageInfo = { +startReached: boolean, +lastNavigatedTo: number, +lastPruned: number, }; type PersistedMessageStore = { +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: number, +threads: { +[threadID: string]: PersistedThreadMessageInfo }, }; const messageStoreMessagesBlocklistTransform: Transform = createTransform( (state: MessageStore): PersistedMessageStore => { const { messages, threads, ...messageStoreSansMessages } = state; // We also do not want to persist `messageStore.threads[ID].messageIDs` // because they can be deterministically computed based on messages we have // from SQLite const threadsToPersist = {}; for (const threadID in threads) { const { messageIDs, ...threadsData } = threads[threadID]; threadsToPersist[threadID] = threadsData; } return { ...messageStoreSansMessages, threads: threadsToPersist }; }, (state: PersistedMessageStore): MessageStore => { const { threads: persistedThreads, ...messageStore } = state; const threads = {}; for (const threadID in persistedThreads) { threads[threadID] = { ...persistedThreads[threadID], messageIDs: [] }; } return { ...messageStore, threads, messages: {} }; }, { whitelist: ['messageStore'] }, ); const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: [ 'loadingStatuses', 'lifecycleState', 'dimensions', 'draftStore', 'connectivity', 'deviceOrientation', 'frozen', 'threadStore', 'storeLoaded', ], debug: __DEV__, - version: 31, + version: 32, transforms: [messageStoreMessagesBlocklistTransform], migrate: (createMigrate(migrations, { debug: __DEV__ }): any), timeout: ((__DEV__ ? 0 : undefined): number | void), }; const codeVersion: number = commCoreModule.getCodeVersion(); // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor(): empty { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor };