diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index bde881b50..ffa2cd065 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,412 +1,367 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _map from 'lodash/fp/map'; import _orderBy from 'lodash/fp/orderBy'; import _memoize from 'lodash/memoize'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { messageKey, robotextForMessageInfo, createMessageInfo, getMostRecentNonLocalMessageID, } from '../shared/message-utils'; import { threadIsTopLevel, threadInChatList } from '../shared/thread-utils'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, - messageInfoPropType, - localMessageInfoPropType, messageTypes, isComposableMessageType, } from '../types/message-types'; import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, - threadInfoPropType, type RawThreadInfo, type SidebarInfo, maxReadSidebars, maxUnreadSidebars, } from '../types/thread-types'; -import { userInfoPropType } from '../types/user-types'; import type { UserInfo } from '../types/user-types'; import { threeDays } from '../utils/date-utils'; import { threadInfoSelector, sidebarInfoSelector } from './thread-selectors'; type SidebarItem = | {| ...SidebarInfo, +type: 'sidebar', |} | {| +type: 'seeMore', +unread: boolean, +showingSidebarsInline: boolean, |}; export type ChatThreadItem = {| +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, +pendingPersonalThreadUserInfo?: UserInfo, |}; -const chatThreadItemPropType = PropTypes.exact({ - type: PropTypes.oneOf(['chatThreadItem']).isRequired, - threadInfo: threadInfoPropType.isRequired, - mostRecentMessageInfo: messageInfoPropType, - mostRecentNonLocalMessage: PropTypes.string, - lastUpdatedTime: PropTypes.number.isRequired, - lastUpdatedTimeIncludingSidebars: PropTypes.number.isRequired, - sidebars: PropTypes.arrayOf( - PropTypes.oneOfType([ - PropTypes.exact({ - type: PropTypes.oneOf(['sidebar']).isRequired, - threadInfo: threadInfoPropType.isRequired, - lastUpdatedTime: PropTypes.number.isRequired, - mostRecentNonLocalMessage: PropTypes.string, - }), - PropTypes.exact({ - type: PropTypes.oneOf(['seeMore']).isRequired, - unread: PropTypes.bool.isRequired, - showingSidebarsInline: PropTypes.bool.isRequired, - }), - ]), - ).isRequired, - pendingPersonalThreadUserInfo: userInfoPropType, -}); const messageInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: MessageInfo } = createObjectSelector( (state: BaseAppState<*>) => state.messageStore.messages, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoSelector, createMessageInfo, ); function getMostRecentMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, ): ?MessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (let messageID of thread.messageIDs) { return messages[messageID]; } return null; } function getLastUpdatedTime( threadInfo: ThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, sidebarInfos: ?$ReadOnlyArray, ): ChatThreadItem { const mostRecentMessageInfo = getMostRecentMessageInfo( threadInfo, messageStore, messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( threadInfo, messageStore, ); const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo); const sidebars = sidebarInfos ?? []; const allSidebarItems = sidebars.map((sidebarInfo) => ({ type: 'sidebar', ...sidebarInfo, })); const lastUpdatedTimeIncludingSidebars = allSidebarItems.length > 0 ? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime) : lastUpdatedTime; const numUnreadSidebars = allSidebarItems.filter( (sidebar) => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const threeDaysAgo = Date.now() - threeDays; const sidebarItems = []; for (const sidebar of allSidebarItems) { if (sidebarItems.length >= maxUnreadSidebars) { break; } else if (sidebar.threadInfo.currentUser.unread) { sidebarItems.push(sidebar); } else if ( sidebar.lastUpdatedTime > threeDaysAgo && numReadSidebarsToShow > 0 ) { sidebarItems.push(sidebar); numReadSidebarsToShow--; } } if (sidebarItems.length < allSidebarItems.length) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, showingSidebarsInline: sidebarItems.length !== 0, }); } return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, mostRecentNonLocalMessage, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars: sidebarItems, }; } const chatListData: ( state: BaseAppState<*>, ) => ChatThreadItem[] = createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, sidebarInfoSelector, ( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, sidebarInfos: { [id: string]: $ReadOnlyArray }, ): ChatThreadItem[] => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, threadIsTopLevel, ), ); function useFlattenedChatListData(): ChatThreadItem[] { const threadInfos = useSelector(threadInfoSelector); const messageInfos = useSelector(messageInfoSelector); const sidebarInfos = useSelector(sidebarInfoSelector); const messageStore = useSelector((state) => state.messageStore); return React.useMemo( () => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, threadInChatList, ), [messageInfos, messageStore, sidebarInfos, threadInfos], ); } function getChatThreadItems( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, sidebarInfos: { [id: string]: $ReadOnlyArray }, filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): ChatThreadItem[] { return _flow( _filter(filterFunction), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, sidebarInfos[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos); } export type RobotextChatMessageInfoItem = {| itemType: 'message', messageInfo: RobotextMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, robotext: string, |}; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | {| itemType: 'message', messageInfo: ComposableMessageInfo, localMessageInfo: ?LocalMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, |}; export type ChatMessageItem = {| itemType: 'loader' |} | ChatMessageInfoItem; -const chatMessageItemPropType = PropTypes.oneOfType([ - PropTypes.shape({ - itemType: PropTypes.oneOf(['loader']).isRequired, - }), - PropTypes.shape({ - itemType: PropTypes.oneOf(['message']).isRequired, - messageInfo: messageInfoPropType.isRequired, - localMessageInfo: localMessageInfoPropType, - startsConversation: PropTypes.bool.isRequired, - startsCluster: PropTypes.bool.isRequired, - endsCluster: PropTypes.bool.isRequired, - robotext: PropTypes.string, - }), -]); const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; if (!thread) { return []; } const threadMessageInfos = thread.messageIDs .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); const chatMessageItems = []; let lastMessageInfo = null; for (let i = threadMessageInfos.length - 1; i >= 0; i--) { const messageInfo = threadMessageInfos[i]; let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > messageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(messageInfo.type) && lastMessageInfo.creator.id === messageInfo.creator.id ) { startsCluster = false; } } if (startsCluster && chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } const originalMessageInfo = messageInfo.type === messageTypes.SIDEBAR_SOURCE ? messageInfo.initialMessage : messageInfo; if (isComposableMessageType(originalMessageInfo.type)) { // We use these invariants instead of just checking the messageInfo.type // directly in the conditional above so that isComposableMessageType can // be the source of truth invariant( originalMessageInfo.type === messageTypes.TEXT || originalMessageInfo.type === messageTypes.IMAGES || originalMessageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(originalMessageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfo: originalMessageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, }); } else { invariant( originalMessageInfo.type !== messageTypes.TEXT && originalMessageInfo.type !== messageTypes.IMAGES && originalMessageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( originalMessageInfo, threadInfos[threadID], ); chatMessageItems.push({ itemType: 'message', messageInfo: originalMessageInfo, startsConversation, startsCluster, endsCluster: false, robotext, }); } lastMessageInfo = messageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); if (thread.startReached) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, threadInfoSelector, ( messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] => createChatMessageItems(threadID, messageStore, messageInfos, threadInfos), ); const messageListData: ( threadID: string, ) => (state: BaseAppState<*>) => ChatMessageItem[] = _memoize( baseMessageListData, ); export { messageInfoSelector, createChatThreadItem, - chatThreadItemPropType, chatListData, - chatMessageItemPropType, createChatMessageItems, messageListData, useFlattenedChatListData, }; diff --git a/lib/types/history-types.js b/lib/types/history-types.js index 8a696a050..47ba45f1a 100644 --- a/lib/types/history-types.js +++ b/lib/types/history-types.js @@ -1,50 +1,31 @@ // @flow import PropTypes from 'prop-types'; export type HistoryMode = 'day' | 'entry'; -export type RawHistoryRevisionInfo = {| - id: string, - entryID: string, - authorID: string, - text: string, - lastUpdate: number, - deleted: boolean, - threadID: string, -|}; -export const rawHistoryRevisionInfoPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - entryID: PropTypes.string.isRequired, - authorID: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - lastUpdate: PropTypes.number.isRequired, - deleted: PropTypes.bool.isRequired, - threadID: PropTypes.string.isRequired, -}); - export type HistoryRevisionInfo = {| id: string, entryID: string, author: ?string, text: string, lastUpdate: number, deleted: boolean, threadID: string, |}; export const historyRevisionInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, entryID: PropTypes.string.isRequired, author: PropTypes.string, text: PropTypes.string.isRequired, lastUpdate: PropTypes.number.isRequired, deleted: PropTypes.bool.isRequired, threadID: PropTypes.string.isRequired, }); export type FetchEntryRevisionInfosRequest = {| id: string, |}; export type FetchEntryRevisionInfosResult = {| result: $ReadOnlyArray, |}; diff --git a/lib/types/media-types.js b/lib/types/media-types.js index ba252a137..36858dab0 100644 --- a/lib/types/media-types.js +++ b/lib/types/media-types.js @@ -1,924 +1,918 @@ // @flow import PropTypes from 'prop-types'; import type { Shape } from './core'; import { type Platform, platformPropType } from './device-types'; export type Dimensions = $ReadOnly<{| height: number, width: number, |}>; export const dimensionsPropType = PropTypes.shape({ height: PropTypes.number.isRequired, width: PropTypes.number.isRequired, }); 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 Video = {| id: string, uri: string, type: 'video', dimensions: Dimensions, loop?: boolean, // stored on native only during creation in case retry needed after state lost localMediaSelection?: NativeMediaSelection, |}; export type Media = Image | Video; export type Corners = Shape<{| topLeft: boolean, topRight: boolean, bottomLeft: boolean, bottomRight: boolean, |}>; export type MediaInfo = | {| ...Image, corners: Corners, index: number, |} | {| ...Video, corners: Corners, index: number, |}; -export const mediaTypePropType = PropTypes.oneOf(['photo', 'video']); - -const mediaPropTypes = { - id: PropTypes.string.isRequired, - uri: PropTypes.string.isRequired, - type: mediaTypePropType.isRequired, - dimensions: dimensionsPropType.isRequired, - filename: PropTypes.string, -}; - -export const mediaPropType = PropTypes.shape(mediaPropTypes); - export const cornersPropType = PropTypes.shape({ topLeft: PropTypes.bool, topRight: PropTypes.bool, bottomLeft: PropTypes.bool, bottomRight: PropTypes.bool, }); +export const mediaTypePropType = PropTypes.oneOf(['photo', 'video']); + export const mediaInfoPropType = PropTypes.shape({ - ...mediaPropTypes, + id: PropTypes.string.isRequired, + uri: PropTypes.string.isRequired, + type: mediaTypePropType.isRequired, + dimensions: dimensionsPropType.isRequired, + filename: PropTypes.string, corners: cornersPropType.isRequired, index: PropTypes.number.isRequired, }); 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 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 |}; const photoLibrarySelectionPropType = PropTypes.shape({ step: PropTypes.oneOf(['photo_library']).isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, mediaNativeID: PropTypes.string.isRequired, selectTime: PropTypes.number.isRequired, sendTime: PropTypes.number.isRequired, retries: PropTypes.number.isRequired, }); const videoLibrarySelectionPropType = PropTypes.shape({ step: PropTypes.oneOf(['video_library']).isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, mediaNativeID: PropTypes.string.isRequired, selectTime: PropTypes.number.isRequired, sendTime: PropTypes.number.isRequired, retries: PropTypes.number.isRequired, duration: PropTypes.number.isRequired, }); export const mediaLibrarySelectionPropType = PropTypes.oneOfType([ photoLibrarySelectionPropType, videoLibrarySelectionPropType, ]); 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, |}; const photoPasteSelectionPropType = PropTypes.exact({ step: PropTypes.oneOf(['photo_paste']).isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, selectTime: PropTypes.number.isRequired, sendTime: PropTypes.number.isRequired, retries: PropTypes.number.isRequired, }); 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 | {| step: 'video_ffmpeg_transcode', success: boolean, exceptionMessage: ?string, time: number, // ms returnCode: ?number, newPath: ?string, stats: ?FFmpegStatistics, |} | 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: '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, |}; export const mediaMissionStepPropType = PropTypes.oneOfType([ photoLibrarySelectionPropType, videoLibrarySelectionPropType, photoPasteSelectionPropType, PropTypes.shape({ step: PropTypes.oneOf(['photo_capture']).isRequired, time: PropTypes.number.isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, captureTime: PropTypes.number.isRequired, selectTime: PropTypes.number.isRequired, sendTime: PropTypes.number.isRequired, retries: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['web_selection']).isRequired, filename: PropTypes.string.isRequired, size: PropTypes.number.isRequired, mime: PropTypes.string.isRequired, selectTime: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['asset_info_fetch']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, localURI: PropTypes.string, orientation: PropTypes.number, }), PropTypes.shape({ step: PropTypes.oneOf(['stat_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, fileSize: PropTypes.number, }), PropTypes.shape({ step: PropTypes.oneOf(['read_file_header']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, mime: PropTypes.string, mediaType: mediaTypePropType, }), PropTypes.shape({ step: PropTypes.oneOf(['determine_file_type']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, inputFilename: PropTypes.string.isRequired, outputMIME: PropTypes.string, outputMediaType: mediaTypePropType, outputFilename: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['frame_count']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, mime: PropTypes.string.isRequired, hasMultipleFrames: PropTypes.bool, }), PropTypes.shape({ step: PropTypes.oneOf(['photo_manipulation']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, manipulation: PropTypes.object.isRequired, newMIME: PropTypes.string, newDimensions: dimensionsPropType, newURI: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['video_probe']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, validFormat: PropTypes.bool.isRequired, duration: PropTypes.number, codec: PropTypes.string, format: PropTypes.arrayOf(PropTypes.string), dimensions: dimensionsPropType, }), PropTypes.shape({ step: PropTypes.oneOf(['video_ffmpeg_transcode']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, returnCode: PropTypes.number, newPath: PropTypes.string, stats: PropTypes.object, }), PropTypes.shape({ step: PropTypes.oneOf(['dispose_temporary_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['save_media']).isRequired, uri: PropTypes.string.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['permissions_check']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, platform: platformPropType.isRequired, permissions: PropTypes.arrayOf(PropTypes.string).isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['make_directory']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['android_scan_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['fetch_file_hash']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, hash: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['copy_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, source: PropTypes.string.isRequired, destination: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['ios_save_to_library']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['fetch_blob']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, inputURI: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, size: PropTypes.number, mime: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['data_uri_from_blob']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, first255Chars: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['array_buffer_from_blob']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['mime_check']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, mime: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['write_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, length: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['exif_fetch']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, orientation: PropTypes.number, }), PropTypes.shape({ step: PropTypes.oneOf(['preload_image']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, dimensions: dimensionsPropType, }), PropTypes.shape({ step: PropTypes.oneOf(['reorient_image']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['upload']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, inputFilename: PropTypes.string.isRequired, outputMediaType: mediaTypePropType, outputURI: PropTypes.string, outputDimensions: dimensionsPropType, outputLoop: PropTypes.bool, hasWiFi: PropTypes.bool, }), PropTypes.shape({ step: PropTypes.oneOf(['wait_for_capture_uri_unload']).isRequired, success: PropTypes.bool.isRequired, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, }), ]); export const mediaMissionPropType = PropTypes.shape({ steps: PropTypes.arrayOf(mediaMissionStepPropType).isRequired, result: PropTypes.oneOfType([ PropTypes.shape({ success: PropTypes.oneOf([true]).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['no_file_path']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['file_stat_failed']).isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['photo_manipulation_failed']).isRequired, size: PropTypes.number.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['media_type_fetch_failed']).isRequired, detectedMIME: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['mime_type_mismatch']).isRequired, reportedMediaType: mediaTypePropType.isRequired, reportedMIME: PropTypes.string.isRequired, detectedMIME: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['http_upload_failed']).isRequired, exceptionMessage: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['video_too_long']).isRequired, duration: PropTypes.number.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['video_probe_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['video_transcode_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['processing_exception']).isRequired, time: PropTypes.number.isRequired, exceptionMessage: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['save_unsupported']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['missing_permission']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['make_directory_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['resolve_failed']).isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['save_to_library_failed']).isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['fetch_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['data_uri_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['array_buffer_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['mime_check_failed']).isRequired, mime: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['write_file_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['fetch_file_hash_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['copy_file_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['exif_fetch_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['reorient_image_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['web_sibling_validation_failed']).isRequired, }), ]), userTime: PropTypes.number.isRequired, totalTime: PropTypes.number.isRequired, }); diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 18232de96..aeb7bb4d2 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,909 +1,717 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import type { FetchResultInfoInterface } from '../utils/fetch-json'; -import { type Media, type Image, mediaPropType } from './media-types'; -import { - type ThreadInfo, - threadInfoPropType, - type ThreadType, - threadTypePropType, -} from './thread-types'; -import { - type RelativeUserInfo, - relativeUserInfoPropType, - type UserInfos, -} from './user-types'; +import { type Media, type Image } from './media-types'; +import { type ThreadInfo, type ThreadType } from './thread-types'; +import { type RelativeUserInfo, type UserInfos } from './user-types'; export const messageTypes = Object.freeze({ TEXT: 0, CREATE_THREAD: 1, ADD_MEMBERS: 2, CREATE_SUB_THREAD: 3, CHANGE_SETTINGS: 4, REMOVE_MEMBERS: 5, CHANGE_ROLE: 6, LEAVE_THREAD: 7, JOIN_THREAD: 8, CREATE_ENTRY: 9, EDIT_ENTRY: 10, DELETE_ENTRY: 11, RESTORE_ENTRY: 12, // When the server has a message to deliver that the client can't properly // render because the client is too old, the server will send this message // type instead. Consequently, there is no MessageData for UNSUPPORTED - just // a RawMessageInfo and a MessageInfo. Note that native/persist.js handles // converting these MessageInfos when the client is upgraded. UNSUPPORTED: 13, IMAGES: 14, MULTIMEDIA: 15, UPDATE_RELATIONSHIP: 16, SIDEBAR_SOURCE: 17, CREATE_SIDEBAR: 18, }); export type MessageType = $Values; export function assertMessageType(ourMessageType: number): MessageType { invariant( ourMessageType === 0 || ourMessageType === 1 || ourMessageType === 2 || ourMessageType === 3 || ourMessageType === 4 || ourMessageType === 5 || ourMessageType === 6 || ourMessageType === 7 || ourMessageType === 8 || ourMessageType === 9 || ourMessageType === 10 || ourMessageType === 11 || ourMessageType === 12 || ourMessageType === 13 || ourMessageType === 14 || ourMessageType === 15 || ourMessageType === 16 || ourMessageType === 17 || ourMessageType === 18, 'number is not MessageType enum', ); return ourMessageType; } const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function messageDataLocalID(messageData: MessageData) { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assetMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type TextMessageData = {| type: 0, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, text: string, |}; type CreateThreadMessageData = {| type: 1, threadID: string, creatorID: string, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadID: ?string, color: string, memberIDs: string[], |}, |}; type AddMembersMessageData = {| type: 2, threadID: string, creatorID: string, time: number, addedUserIDs: string[], |}; type CreateSubthreadMessageData = {| type: 3, threadID: string, creatorID: string, time: number, childThreadID: string, |}; type ChangeSettingsMessageData = {| type: 4, threadID: string, creatorID: string, time: number, field: string, value: string | number, |}; type RemoveMembersMessageData = {| type: 5, threadID: string, creatorID: string, time: number, removedUserIDs: string[], |}; type ChangeRoleMessageData = {| type: 6, threadID: string, creatorID: string, time: number, userIDs: string[], newRole: string, |}; type LeaveThreadMessageData = {| type: 7, threadID: string, creatorID: string, time: number, |}; type JoinThreadMessageData = {| type: 8, threadID: string, creatorID: string, time: number, |}; type CreateEntryMessageData = {| type: 9, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type EditEntryMessageData = {| type: 10, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type DeleteEntryMessageData = {| type: 11, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type RestoreEntryMessageData = {| type: 12, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; export type ImagesMessageData = {| type: 14, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type MediaMessageData = {| type: 15, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type UpdateRelationshipMessageData = {| +type: 16, +threadID: string, +creatorID: string, +targetID: string, +time: number, +operation: 'request_sent' | 'request_accepted', |}; export type SidebarSourceMessageData = {| +type: 17, +threadID: string, +creatorID: string, +time: number, +initialMessage: RawComposableMessageInfo | RawRobotextMessageInfo, |}; export type CreateSidebarMessageData = {| +type: 18, +threadID: string, +creatorID: string, +time: number, +initialThreadState: {| +name: ?string, +parentThreadID: string, +color: string, +memberIDs: string[], |}, |}; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData | UpdateRelationshipMessageData | SidebarSourceMessageData | CreateSidebarMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawTextMessageInfo = {| ...TextMessageData, id?: string, // null if local copy without ID yet |}; export type RawImagesMessageInfo = {| ...ImagesMessageData, id?: string, // null if local copy without ID yet |}; export type RawMediaMessageInfo = {| ...MediaMessageData, id?: string, // null if local copy without ID yet |}; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; type RawRobotextMessageInfo = | {| ...CreateThreadMessageData, id: string, |} | {| ...AddMembersMessageData, id: string, |} | {| ...CreateSubthreadMessageData, id: string, |} | {| ...ChangeSettingsMessageData, id: string, |} | {| ...RemoveMembersMessageData, id: string, |} | {| ...ChangeRoleMessageData, id: string, |} | {| ...LeaveThreadMessageData, id: string, |} | {| ...JoinThreadMessageData, id: string, |} | {| ...CreateEntryMessageData, id: string, |} | {| ...EditEntryMessageData, id: string, |} | {| ...DeleteEntryMessageData, id: string, |} | {| ...RestoreEntryMessageData, id: string, |} | {| ...UpdateRelationshipMessageData, id: string, |} | {| ...CreateSidebarMessageData, id: string, |} | {| type: 13, id: string, threadID: string, creatorID: string, time: number, robotext: string, unsupportedMessageInfo: Object, |}; export type RawSidebarSourceMessageInfo = {| ...SidebarSourceMessageData, id: string, |}; export type RawMessageInfo = | RawComposableMessageInfo | RawRobotextMessageInfo | RawSidebarSourceMessageInfo; export type LocallyComposedMessageInfo = { localID: string, threadID: string, ... }; export type TextMessageInfo = {| type: 0, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp text: string, |}; export type ImagesMessageInfo = {| type: 14, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MediaMessageInfo = {| type: 15, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | {| type: 1, id: string, threadID: string, creator: RelativeUserInfo, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadInfo: ?ThreadInfo, color: string, otherMembers: RelativeUserInfo[], |}, |} | {| type: 2, id: string, threadID: string, creator: RelativeUserInfo, time: number, addedMembers: RelativeUserInfo[], |} | {| type: 3, id: string, threadID: string, creator: RelativeUserInfo, time: number, childThreadInfo: ThreadInfo, |} | {| type: 4, id: string, threadID: string, creator: RelativeUserInfo, time: number, field: string, value: string | number, |} | {| type: 5, id: string, threadID: string, creator: RelativeUserInfo, time: number, removedMembers: RelativeUserInfo[], |} | {| type: 6, id: string, threadID: string, creator: RelativeUserInfo, time: number, members: RelativeUserInfo[], newRole: string, |} | {| type: 7, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 8, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 9, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 10, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 11, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 12, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 13, id: string, threadID: string, creator: RelativeUserInfo, time: number, robotext: string, unsupportedMessageInfo: Object, |} | {| +type: 16, +id: string, +threadID: string, +creator: RelativeUserInfo, +target: RelativeUserInfo, +time: number, +operation: 'request_sent' | 'request_accepted', |} | {| +type: 18, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +initialThreadState: {| +name: ?string, +parentThreadInfo: ThreadInfo, +color: string, +otherMembers: RelativeUserInfo[], |}, |}; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo; export type SidebarSourceMessageInfo = {| +type: 17, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +initialMessage: ComposableMessageInfo | RobotextMessageInfo, |}; export type MessageInfo = | ComposableMessageInfo | RobotextMessageInfo | SidebarSourceMessageInfo; -const messageInfoPropTypes = [ - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.TEXT]).isRequired, - id: PropTypes.string, - localID: PropTypes.string, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - text: PropTypes.string.isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.CREATE_THREAD]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - initialThreadState: PropTypes.shape({ - type: threadTypePropType.isRequired, - name: PropTypes.string, - parentThreadInfo: threadInfoPropType, - color: PropTypes.string.isRequired, - otherMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, - }).isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.ADD_MEMBERS]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - addedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.CREATE_SUB_THREAD]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - childThreadInfo: threadInfoPropType.isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.CHANGE_SETTINGS]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - field: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.REMOVE_MEMBERS]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - removedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.CHANGE_ROLE]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - members: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, - newRole: PropTypes.string.isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.LEAVE_THREAD]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.JOIN_THREAD]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.CREATE_ENTRY]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - entryID: PropTypes.string.isRequired, - date: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.EDIT_ENTRY]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - entryID: PropTypes.string.isRequired, - date: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.DELETE_ENTRY]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - entryID: PropTypes.string.isRequired, - date: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.RESTORE_ENTRY]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - entryID: PropTypes.string.isRequired, - date: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.UNSUPPORTED]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - robotext: PropTypes.string.isRequired, - unsupportedMessageInfo: PropTypes.object.isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.IMAGES]).isRequired, - id: PropTypes.string, - localID: PropTypes.string, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - media: PropTypes.arrayOf(mediaPropType).isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.MULTIMEDIA]).isRequired, - id: PropTypes.string, - localID: PropTypes.string, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - media: PropTypes.arrayOf(mediaPropType).isRequired, - }), - PropTypes.exact({ - type: PropTypes.oneOf([messageTypes.UPDATE_RELATIONSHIP]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - target: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - operation: PropTypes.oneOf(['request_sent', 'request_accepted']), - }), - PropTypes.exact({ - type: PropTypes.oneOf([messageTypes.CREATE_SIDEBAR]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - initialThreadState: PropTypes.shape({ - name: PropTypes.string, - parentThreadInfo: threadInfoPropType.isRequired, - color: PropTypes.string.isRequired, - otherMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, - }).isRequired, - }), -]; - -export const messageInfoPropType = PropTypes.oneOfType([ - ...messageInfoPropTypes, - PropTypes.exact({ - type: PropTypes.oneOf([messageTypes.SIDEBAR_SOURCE]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - initialMessage: PropTypes.oneOfType(messageInfoPropTypes).isRequired, - }), -]); - export type ThreadMessageInfo = {| messageIDs: string[], startReached: boolean, lastNavigatedTo: number, // millisecond timestamp lastPruned: number, // millisecond timestamp |}; // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. export type LocalMessageInfo = {| sendFailed?: boolean, |}; export const localMessageInfoPropType = PropTypes.shape({ sendFailed: PropTypes.bool, }); export type MessageStore = {| messages: { [id: string]: RawMessageInfo }, threads: { [threadID: string]: ThreadMessageInfo }, local: { [id: string]: LocalMessageInfo }, currentAsOf: number, |}; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: ThreadSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export type ThreadCursors = { [threadID: string]: ?string }; export type ThreadSelectionCriteria = {| threadCursors?: ?ThreadCursors, joinedThreads?: ?boolean, |}; export type FetchMessageInfosRequest = {| cursors: ThreadCursors, numberPerThread?: ?number, |}; export type FetchMessageInfosResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: UserInfos, |}; export type FetchMessageInfosResult = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, |}; export type FetchMessageInfosPayload = {| threadID: string, rawMessageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatus, |}; export type MessagesResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, currentAsOf: number, |}; export const defaultNumberPerThread = 20; export type SendMessageResponse = {| +newMessageInfo: RawMessageInfo, |}; export type SendMessageResult = {| +id: string, +time: number, +interface: FetchResultInfoInterface, |}; export type SendMessagePayload = {| +localID: string, +serverID: string, +threadID: string, +time: number, +interface: FetchResultInfoInterface, |}; export type SendTextMessageRequest = {| threadID: string, localID?: string, text: string, |}; export type SendMultimediaMessageRequest = {| threadID: string, localID: string, mediaIDs: $ReadOnlyArray, |}; // Used for the message info included in log-in type actions export type GenericMessagesResult = {| messageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatuses, watchedIDsAtRequestTime: $ReadOnlyArray, currentAsOf: number, |}; export type SaveMessagesPayload = {| rawMessageInfos: $ReadOnlyArray, updatesCurrentAsOf: number, |}; export type NewMessagesPayload = {| messagesResult: MessagesResponse, |}; export type MessageStorePrunePayload = {| threadIDs: $ReadOnlyArray, |}; diff --git a/lib/types/user-types.js b/lib/types/user-types.js index 395244a4b..401ad9c61 100644 --- a/lib/types/user-types.js +++ b/lib/types/user-types.js @@ -1,111 +1,105 @@ // @flow import PropTypes from 'prop-types'; import type { UserRelationshipStatus } from './relationship-types'; import type { UserInconsistencyReportCreationRequest } from './report-types'; export type GlobalUserInfo = {| +id: string, +username: ?string, |}; export type GlobalAccountUserInfo = {| +id: string, +username: string, |}; export type UserInfo = {| +id: string, +username: ?string, +relationshipStatus?: UserRelationshipStatus, |}; export type UserInfos = { +[id: string]: UserInfo }; export const userInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string, relationshipStatus: PropTypes.number, }); export type AccountUserInfo = {| +id: string, +username: string, +relationshipStatus?: UserRelationshipStatus, |}; export const accountUserInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, relationshipStatus: PropTypes.number, }); export type UserStore = {| +userInfos: UserInfos, +inconsistencyReports: $ReadOnlyArray, |}; export type RelativeUserInfo = {| +id: string, +username: ?string, +isViewer: boolean, |}; -export const relativeUserInfoPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - username: PropTypes.string, - isViewer: PropTypes.bool.isRequired, -}); - export type LoggedInUserInfo = {| +id: string, +username: string, +email: string, +emailVerified: boolean, |}; export type LoggedOutUserInfo = {| +id: string, +anonymous: true, |}; export type CurrentUserInfo = LoggedInUserInfo | LoggedOutUserInfo; export const currentUserPropType = PropTypes.oneOfType([ PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, email: PropTypes.string.isRequired, emailVerified: PropTypes.bool.isRequired, }), PropTypes.shape({ id: PropTypes.string.isRequired, anonymous: PropTypes.oneOf([true]).isRequired, }), ]); export type AccountUpdate = {| +updatedFields: {| +email?: ?string, +password?: ?string, |}, +currentPassword: string, |}; export type UserListItem = {| +id: string, +username: string, +disabled?: boolean, +notice?: string, +alertText?: string, +alertTitle?: string, |}; export const userListItemPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, disabled: PropTypes.bool, notice: PropTypes.string, alertText: PropTypes.string, alertTitle: PropTypes.string, }); diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index 18b73b2f3..217d2d924 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,188 +1,171 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { StyleSheet, View, Platform } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; import { createMessageReply } from 'lib/shared/message-utils'; import { assertComposableMessageType } from 'lib/types/message-types'; -import { - inputStatePropType, - type InputState, - InputStateContext, -} from '../input/input-state'; +import { type InputState, InputStateContext } from '../input/input-state'; import { useSelector } from '../redux/redux-utils'; -import { type Colors, colorsPropType, useColors } from '../themes/colors'; +import { type Colors, useColors } from '../themes/colors'; import { composedMessageMaxWidthSelector } from './composed-message-width'; import { FailedSend } from './failed-send.react'; import { MessageHeader } from './message-header.react'; import type { ChatMessageInfoItemWithHeight } from './message.react'; import SwipeableMessage from './swipeable-message.react'; const clusterEndHeight = 7; type BaseProps = {| ...React.ElementConfig, +item: ChatMessageInfoItemWithHeight, +sendFailed: boolean, +focused: boolean, +canSwipe?: boolean, +children: React.Node, |}; type Props = {| ...BaseProps, // Redux state +composedMessageMaxWidth: number, +colors: Colors, // withInputState +inputState: ?InputState, |}; class ComposedMessage extends React.PureComponent { - static propTypes = { - item: chatMessageItemPropType.isRequired, - sendFailed: PropTypes.bool.isRequired, - focused: PropTypes.bool.isRequired, - canSwipe: PropTypes.bool, - children: PropTypes.node.isRequired, - composedMessageMaxWidth: PropTypes.number.isRequired, - colors: colorsPropType.isRequired, - inputState: inputStatePropType, - }; - render() { assertComposableMessageType(this.props.item.messageInfo.type); const { item, sendFailed, focused, canSwipe, children, composedMessageMaxWidth, colors, inputState, ...viewProps } = this.props; const { id, creator } = item.messageInfo; const { isViewer } = creator; const alignStyle = isViewer ? styles.rightChatBubble : styles.leftChatBubble; const containerStyle = [ styles.alignment, { marginBottom: 5 + (item.endsCluster ? clusterEndHeight : 0) }, ]; const messageBoxStyle = { maxWidth: composedMessageMaxWidth }; let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconName; let deliveryIconColor = `#${item.threadInfo.color}`; if (id !== null && id !== undefined) { deliveryIconName = 'check-circle'; } else if (sendFailed) { deliveryIconName = 'x-circle'; deliveryIconColor = colors.redText; failedSendInfo = ; } else { deliveryIconName = 'circle'; } deliveryIcon = ( ); } const fullMessageBoxStyle = [styles.messageBox, messageBoxStyle]; let messageBox; if (canSwipe && (Platform.OS !== 'android' || Platform.Version >= 21)) { messageBox = ( {children} ); } else { messageBox = {children}; } return ( {messageBox} {deliveryIcon} {failedSendInfo} ); } reply = () => { const { inputState, item } = this.props; invariant(inputState, 'inputState should be set in reply'); invariant(item.messageInfo.text, 'text should be set in reply'); inputState.addReply(createMessageReply(item.messageInfo.text)); }; } const styles = StyleSheet.create({ alignment: { marginLeft: 12, marginRight: 7, }, content: { alignItems: 'center', flexDirection: 'row', }, icon: { fontSize: 16, textAlign: 'center', }, iconContainer: { marginLeft: 2, width: 16, }, leftChatBubble: { justifyContent: 'flex-start', }, messageBox: { marginRight: 5, }, rightChatBubble: { justifyContent: 'flex-end', }, }); const ConnectedComposedMessage = React.memo( function ConnectedComposedMessage(props: BaseProps) { const composedMessageMaxWidth = useSelector( composedMessageMaxWidthSelector, ); const colors = useColors(); const inputState = React.useContext(InputStateContext); return ( ); }, ); export { ConnectedComposedMessage as ComposedMessage, clusterEndHeight }; diff --git a/native/chat/failed-send.react.js b/native/chat/failed-send.react.js index 4453d28eb..21b425608 100644 --- a/native/chat/failed-send.react.js +++ b/native/chat/failed-send.react.js @@ -1,170 +1,158 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, View } from 'react-native'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; import { messageID } from 'lib/shared/message-utils'; import { messageTypes, type RawMessageInfo } from 'lib/types/message-types'; import Button from '../components/button.react'; -import { - type InputState, - inputStatePropType, - InputStateContext, -} from '../input/input-state'; +import { type InputState, InputStateContext } from '../input/input-state'; import { useSelector } from '../redux/redux-utils'; import { useStyles } from '../themes/colors'; import type { ChatMessageInfoItemWithHeight } from './message.react'; import multimediaMessageSendFailed from './multimedia-message-send-failed'; import textMessageSendFailed from './text-message-send-failed'; const failedSendHeight = 22; type BaseProps = {| +item: ChatMessageInfoItemWithHeight, |}; type Props = {| ...BaseProps, // Redux state +rawMessageInfo: ?RawMessageInfo, +styles: typeof unboundStyles, // withInputState +inputState: ?InputState, |}; class FailedSend extends React.PureComponent { - static propTypes = { - item: chatMessageItemPropType.isRequired, - rawMessageInfo: PropTypes.object, - styles: PropTypes.objectOf(PropTypes.object).isRequired, - inputState: inputStatePropType, - }; retryingText = false; retryingMedia = false; componentDidUpdate(prevProps: Props) { const newItem = this.props.item; const prevItem = prevProps.item; if ( newItem.messageShapeType === 'multimedia' && prevItem.messageShapeType === 'multimedia' ) { const isFailed = multimediaMessageSendFailed(newItem); const wasFailed = multimediaMessageSendFailed(prevItem); const isDone = newItem.messageInfo.id !== null && newItem.messageInfo.id !== undefined; const wasDone = prevItem.messageInfo.id !== null && prevItem.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingMedia = false; } } else if ( newItem.messageShapeType === 'text' && prevItem.messageShapeType === 'text' ) { const isFailed = textMessageSendFailed(newItem); const wasFailed = textMessageSendFailed(prevItem); const isDone = newItem.messageInfo.id !== null && newItem.messageInfo.id !== undefined; const wasDone = prevItem.messageInfo.id !== null && prevItem.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingText = false; } } } render() { if (!this.props.rawMessageInfo) { return null; } return ( DELIVERY FAILED. ); } retrySend = () => { const { rawMessageInfo } = this.props; if (!rawMessageInfo) { return; } const { inputState } = this.props; invariant( inputState, 'inputState should be initialized before user can hit retry', ); if (rawMessageInfo.type === messageTypes.TEXT) { if (this.retryingText) { return; } this.retryingText = true; inputState.sendTextMessage({ ...rawMessageInfo, time: Date.now(), }); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { const { localID } = rawMessageInfo; invariant(localID, 'failed RawMessageInfo should have localID'); if (this.retryingMedia) { return; } this.retryingMedia = true; inputState.retryMultimediaMessage(localID); } }; } const unboundStyles = { deliveryFailed: { color: 'listSeparatorLabel', paddingHorizontal: 3, }, failedSendInfo: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', marginRight: 20, paddingTop: 5, }, retrySend: { color: 'link', paddingHorizontal: 3, }, }; const ConnectedFailedSend = React.memo(function ConnectedFailedSend( props: BaseProps, ) { const id = messageID(props.item.messageInfo); const rawMessageInfo = useSelector( (state) => state.messageStore.messages[id], ); const styles = useStyles(unboundStyles); const inputState = React.useContext(InputStateContext); return ( ); }); export { ConnectedFailedSend as FailedSend, failedSendHeight }; diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index eb9169b70..17c9c5d70 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,434 +1,407 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { View, TouchableWithoutFeedback } from 'react-native'; import { createSelector } from 'reselect'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from 'lib/actions/message-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; import { messageKey } from 'lib/shared/message-utils'; import { threadInChatList } from 'lib/shared/thread-utils'; import threadWatcher from 'lib/shared/thread-watcher'; import type { FetchMessageInfosPayload } from 'lib/types/message-types'; -import { - type ThreadInfo, - threadInfoPropType, - threadTypes, -} from 'lib/types/thread-types'; +import { type ThreadInfo, threadTypes } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import ListLoadingIndicator from '../components/list-loading-indicator.react'; import { type KeyboardState, - keyboardStatePropType, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, - overlayContextPropType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles, type IndicatorStyle, - indicatorStylePropType, useIndicatorStyle, } from '../themes/colors'; import type { VerticalBounds } from '../types/layout-types'; import type { ViewToken } from '../types/react-native'; import { ChatList } from './chat-list.react'; import type { ChatNavigationProp } from './chat.react'; import type { ChatMessageItemWithHeight } from './message-list-container.react'; -import { - messageListRoutePropType, - messageListNavPropType, -} from './message-list-types'; import { Message, type ChatMessageInfoItemWithHeight } from './message.react'; import RelationshipPrompt from './relationship-prompt.react'; type BaseProps = {| +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +startReached: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +fetchMessagesBeforeCursor: ( threadID: string, beforeMessageID: string, ) => Promise, +fetchMostRecentMessages: ( threadID: string, ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +focusedMessageKey: ?string, +messageListVerticalBounds: ?VerticalBounds, +loadingFromScroll: boolean, |}; type PropsAndState = {| ...Props, ...State, |}; type FlatListExtraData = {| messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, |}; class MessageList extends React.PureComponent { - static propTypes = { - threadInfo: threadInfoPropType.isRequired, - messageListData: PropTypes.arrayOf(chatMessageItemPropType).isRequired, - navigation: messageListNavPropType.isRequired, - route: messageListRoutePropType.isRequired, - startReached: PropTypes.bool.isRequired, - styles: PropTypes.objectOf(PropTypes.object).isRequired, - indicatorStyle: indicatorStylePropType.isRequired, - dispatchActionPromise: PropTypes.func.isRequired, - fetchMessagesBeforeCursor: PropTypes.func.isRequired, - fetchMostRecentMessages: PropTypes.func.isRequired, - overlayContext: overlayContextPropType, - keyboardState: keyboardStatePropType, - }; state: State = { focusedMessageKey: null, messageListVerticalBounds: null, loadingFromScroll: false, }; flatListContainer: ?React.ElementRef; flatListExtraDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.messageListVerticalBounds, (propsAndState: PropsAndState) => propsAndState.focusedMessageKey, (propsAndState: PropsAndState) => propsAndState.navigation, (propsAndState: PropsAndState) => propsAndState.route, ( messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, ) => ({ messageListVerticalBounds, focusedMessageKey, navigation, route, }), ); get flatListExtraData(): FlatListExtraData { return this.flatListExtraDataSelector({ ...this.props, ...this.state }); } componentDidMount() { const { threadInfo } = this.props; if (!threadInChatList(threadInfo)) { threadWatcher.watchID(threadInfo.id); this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages(threadInfo.id), ); } } componentWillUnmount() { const { threadInfo } = this.props; if (!threadInChatList(threadInfo)) { threadWatcher.removeID(threadInfo.id); } } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'MessageList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } static modalOpen(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus === 'open'; } componentDidUpdate(prevProps: Props) { const oldThreadInfo = prevProps.threadInfo; const newThreadInfo = this.props.threadInfo; if (oldThreadInfo.id !== newThreadInfo.id) { if (!threadInChatList(oldThreadInfo)) { threadWatcher.removeID(oldThreadInfo.id); } if (!threadInChatList(newThreadInfo)) { threadWatcher.watchID(newThreadInfo.id); } } const newListData = this.props.messageListData; const oldListData = prevProps.messageListData; if ( this.state.loadingFromScroll && (newListData.length > oldListData.length || this.props.startReached) ) { this.setState({ loadingFromScroll: false }); } const modalIsOpen = MessageList.modalOpen(this.props); const modalWasOpen = MessageList.modalOpen(prevProps); if (!modalIsOpen && modalWasOpen) { this.setState({ focusedMessageKey: null }); } const scrollIsDisabled = MessageList.scrollDisabled(this.props); const scrollWasDisabled = MessageList.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; renderItem = (row: { item: ChatMessageItemWithHeight }) => { if (row.item.itemType === 'loader') { return ( ); } const messageInfoItem: ChatMessageInfoItemWithHeight = row.item; const { messageListVerticalBounds, focusedMessageKey, navigation, route, } = this.flatListExtraData; const focused = messageKey(messageInfoItem.messageInfo) === focusedMessageKey; return ( ); }; toggleMessageFocus = (inMessageKey: string) => { if (this.state.focusedMessageKey === inMessageKey) { this.setState({ focusedMessageKey: null }); } else { this.setState({ focusedMessageKey: inMessageKey }); } }; // Actually header, it's just that our FlatList is inverted ListFooterComponent = () => ; render() { const { messageListData, startReached } = this.props; const footer = startReached ? this.ListFooterComponent : undefined; let relationshipPrompt = null; if (this.props.threadInfo.type === threadTypes.PERSONAL) { relationshipPrompt = ( ); } return ( {relationshipPrompt} ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ messageListVerticalBounds: { height, y: pageY } }); }); }; onViewableItemsChanged = (info: { viewableItems: ViewToken[], changed: ViewToken[], }) => { if (this.state.focusedMessageKey) { let focusedMessageVisible = false; for (let token of info.viewableItems) { if ( token.item.itemType === 'message' && messageKey(token.item.messageInfo) === this.state.focusedMessageKey ) { focusedMessageVisible = true; break; } } if (!focusedMessageVisible) { this.setState({ focusedMessageKey: null }); } } const loader = _find({ key: 'loader' })(info.viewableItems); if (!loader || this.state.loadingFromScroll) { return; } const oldestMessageServerID = this.oldestMessageServerID(); if (oldestMessageServerID) { this.setState({ loadingFromScroll: true }); const threadID = this.props.threadInfo.id; this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } }; oldestMessageServerID(): ?string { const data = this.props.messageListData; for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return null; } } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, header: { height: 12, }, listLoadingIndicator: { flex: 1, }, }; registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); export default React.memo(function ConnectedMessageList( props: BaseProps, ) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const threadID = props.threadInfo.id; const startReached = useSelector( (state) => !!( state.messageStore.threads[threadID] && state.messageStore.threads[threadID].startReached ), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); return ( ); }); diff --git a/native/chat/message-preview.react.js b/native/chat/message-preview.react.js index 2b6dc4918..e102c042a 100644 --- a/native/chat/message-preview.react.js +++ b/native/chat/message-preview.react.js @@ -1,112 +1,101 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { Text } from 'react-native'; import { messagePreviewText } from 'lib/shared/message-utils'; import { threadIsGroupChat } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; -import { - type MessageInfo, - messageInfoPropType, - messageTypes, -} from 'lib/types/message-types'; -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import { type MessageInfo, messageTypes } from 'lib/types/message-types'; +import { type ThreadInfo } from 'lib/types/thread-types'; import { connect } from 'lib/utils/redux-utils'; import { firstLine } from 'lib/utils/string-utils'; import { SingleLine } from '../components/single-line.react'; import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; type Props = {| messageInfo: MessageInfo, threadInfo: ThreadInfo, // Redux state styles: typeof styles, |}; class MessagePreview extends React.PureComponent { - static propTypes = { - messageInfo: messageInfoPropType.isRequired, - threadInfo: threadInfoPropType.isRequired, - styles: PropTypes.objectOf(PropTypes.object).isRequired, - }; - render() { const messageInfo: MessageInfo = this.props.messageInfo.type === messageTypes.SIDEBAR_SOURCE ? this.props.messageInfo.initialMessage : this.props.messageInfo; const unreadStyle = this.props.threadInfo.currentUser.unread ? this.props.styles.unread : null; if (messageInfo.type === messageTypes.TEXT) { let usernameText = null; if ( threadIsGroupChat(this.props.threadInfo) || this.props.threadInfo.name !== '' || messageInfo.creator.isViewer ) { const userString = stringForUser(messageInfo.creator); const username = `${userString}: `; usernameText = ( {username} ); } const firstMessageLine = firstLine(messageInfo.text); return ( {usernameText} {firstMessageLine} ); } else { invariant( messageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'Sidebar source should not be handled here', ); const preview = messagePreviewText(messageInfo, this.props.threadInfo); return ( {preview} ); } } } const styles = { lastMessage: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize: 16, paddingLeft: 10, }, preview: { color: 'listForegroundQuaternaryLabel', }, unread: { color: 'listForegroundLabel', }, username: { color: 'listForegroundQuaternaryLabel', }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(MessagePreview); diff --git a/native/chat/message.react.js b/native/chat/message.react.js index 8a0ba3407..cf4f7f391 100644 --- a/native/chat/message.react.js +++ b/native/chat/message.react.js @@ -1,182 +1,162 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; import { LayoutAnimation, TouchableWithoutFeedback, PixelRatio, } from 'react-native'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; import { messageKey } from 'lib/shared/message-utils'; import { type KeyboardState, - keyboardStatePropType, KeyboardContext, } from '../keyboard/keyboard-state'; import type { NavigationRoute } from '../navigation/route-names'; -import { - type VerticalBounds, - verticalBoundsPropType, -} from '../types/layout-types'; +import { type VerticalBounds } from '../types/layout-types'; import type { LayoutEvent } from '../types/react-native'; import type { ChatNavigationProp } from './chat.react'; -import { - messageListRoutePropType, - messageListNavPropType, -} from './message-list-types'; import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; import { MultimediaMessage, multimediaMessageItemHeight, } from './multimedia-message.react'; import type { ChatRobotextMessageInfoItemWithHeight } from './robotext-message.react'; import { RobotextMessage, robotextMessageItemHeight, } from './robotext-message.react'; import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; import { TextMessage, textMessageItemHeight } from './text-message.react'; import { timestampHeight } from './timestamp.react'; export type ChatMessageInfoItemWithHeight = | ChatRobotextMessageInfoItemWithHeight | ChatTextMessageInfoItemWithHeight | ChatMultimediaMessageInfoItem; function messageItemHeight(item: ChatMessageInfoItemWithHeight) { let height = 0; if (item.messageShapeType === 'text') { height += textMessageItemHeight(item); } else if (item.messageShapeType === 'multimedia') { height += multimediaMessageItemHeight(item); } else { height += robotextMessageItemHeight(item); } if (item.startsConversation) { height += timestampHeight; } return height; } type BaseProps = {| +item: ChatMessageInfoItemWithHeight, +focused: boolean, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, |}; type Props = {| ...BaseProps, // withKeyboardState +keyboardState: ?KeyboardState, |}; class Message extends React.PureComponent { - static propTypes = { - item: chatMessageItemPropType.isRequired, - focused: PropTypes.bool.isRequired, - navigation: messageListNavPropType.isRequired, - route: messageListRoutePropType.isRequired, - toggleFocus: PropTypes.func.isRequired, - verticalBounds: verticalBoundsPropType, - keyboardState: keyboardStatePropType, - }; - componentDidUpdate(prevProps: Props) { if ( (prevProps.focused || prevProps.item.startsConversation) !== (this.props.focused || this.props.item.startsConversation) ) { LayoutAnimation.easeInEaseOut(); } } render() { let message; if (this.props.item.messageShapeType === 'text') { message = ( ); } else if (this.props.item.messageShapeType === 'multimedia') { message = ( ); } else { message = ( ); } const onLayout = __DEV__ ? this.onLayout : undefined; return ( {message} ); } onLayout = (event: LayoutEvent) => { if (this.props.focused) { return; } const measuredHeight = event.nativeEvent.layout.height; const expectedHeight = messageItemHeight(this.props.item); const pixelRatio = 1 / PixelRatio.get(); const distance = Math.abs(measuredHeight - expectedHeight); if (distance < pixelRatio) { return; } const approxMeasuredHeight = Math.round(measuredHeight * 100) / 100; const approxExpectedHeight = Math.round(expectedHeight * 100) / 100; console.log( `Message height for ${this.props.item.messageShapeType} ` + `${messageKey(this.props.item.messageInfo)} was expected to be ` + `${approxExpectedHeight} but is actually ${approxMeasuredHeight}. ` + "This means MessageList's FlatList isn't getting the right item " + 'height for some of its nodes, which is guaranteed to cause glitchy ' + 'behavior. Please investigate!!', ); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const ConnectedMessage = React.memo(function ConnectedMessage( props: BaseProps, ) { const keyboardState = React.useContext(KeyboardContext); return ; }); export { ConnectedMessage as Message, messageItemHeight }; diff --git a/native/chat/multimedia-message-multimedia.react.js b/native/chat/multimedia-message-multimedia.react.js index 8fb951a22..b1644a04b 100644 --- a/native/chat/multimedia-message-multimedia.react.js +++ b/native/chat/multimedia-message-multimedia.react.js @@ -1,342 +1,313 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import Animated from 'react-native-reanimated'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; import { messageKey } from 'lib/shared/message-utils'; -import { type MediaInfo, mediaInfoPropType } from 'lib/types/media-types'; +import { type MediaInfo } from 'lib/types/media-types'; -import { - type PendingMultimediaUpload, - pendingMultimediaUploadPropType, -} from '../input/input-state'; +import { type PendingMultimediaUpload } from '../input/input-state'; import { type KeyboardState, - keyboardStatePropType, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, - overlayContextPropType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { MultimediaModalRouteName, MultimediaTooltipModalRouteName, } from '../navigation/route-names'; -import { type Colors, colorsPropType, useColors } from '../themes/colors'; -import { - type VerticalBounds, - verticalBoundsPropType, -} from '../types/layout-types'; +import { type Colors, useColors } from '../themes/colors'; +import { type VerticalBounds } from '../types/layout-types'; import type { ViewStyle } from '../types/styles'; import type { ChatNavigationProp } from './chat.react'; import InlineMultimedia from './inline-multimedia.react'; -import { - messageListRoutePropType, - messageListNavPropType, -} from './message-list-types'; import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; import { multimediaTooltipHeight } from './multimedia-tooltip-modal.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, sub, interpolate, Extrapolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type BaseProps = {| +mediaInfo: MediaInfo, +item: ChatMultimediaMessageInfoItem, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +verticalBounds: ?VerticalBounds, +verticalOffset: number, +style: ViewStyle, +postInProgress: boolean, +pendingUpload: ?PendingMultimediaUpload, +messageFocused: boolean, +toggleMessageFocus: (messageKey: string) => void, |}; type Props = {| ...BaseProps, // Redux state +colors: Colors, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +opacity: number | Value, |}; class MultimediaMessageMultimedia extends React.PureComponent { - static propTypes = { - mediaInfo: mediaInfoPropType.isRequired, - item: chatMessageItemPropType.isRequired, - navigation: messageListNavPropType.isRequired, - route: messageListRoutePropType.isRequired, - verticalBounds: verticalBoundsPropType, - verticalOffset: PropTypes.number.isRequired, - postInProgress: PropTypes.bool.isRequired, - pendingUpload: pendingMultimediaUploadPropType, - messageFocused: PropTypes.bool.isRequired, - toggleMessageFocus: PropTypes.func.isRequired, - colors: colorsPropType.isRequired, - keyboardState: keyboardStatePropType, - overlayContext: overlayContextPropType, - }; view: ?React.ElementRef; clickable = true; constructor(props: Props) { super(props); this.state = { opacity: this.getOpacity(), }; } static getStableKey(props: Props) { const { item, mediaInfo } = props; return `multimedia|${messageKey(item.messageInfo)}|${mediaInfo.index}`; } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant( overlayContext, 'MultimediaMessageMultimedia should have OverlayContext', ); return overlayContext; } static getModalOverlayPosition(props: Props) { const overlayContext = MultimediaMessageMultimedia.getOverlayContext(props); const { visibleOverlays } = overlayContext; for (let overlay of visibleOverlays) { if ( overlay.routeName === MultimediaModalRouteName && overlay.presentedFrom === props.route.key && overlay.routeKey === MultimediaMessageMultimedia.getStableKey(props) ) { return overlay.position; } } return undefined; } getOpacity() { const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( this.props, ); if (!overlayPosition) { return 1; } return sub( 1, interpolate(overlayPosition, { inputRange: [0.1, 0.11], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), ); } componentDidUpdate(prevProps: Props) { const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( this.props, ); const prevOverlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( prevProps, ); if (overlayPosition !== prevOverlayPosition) { this.setState({ opacity: this.getOpacity() }); } const scrollIsDisabled = MultimediaMessageMultimedia.getOverlayContext(this.props) .scrollBlockingModalStatus !== 'closed'; const scrollWasDisabled = MultimediaMessageMultimedia.getOverlayContext(prevProps) .scrollBlockingModalStatus !== 'closed'; if (!scrollIsDisabled && scrollWasDisabled) { this.clickable = true; } } render() { const { opacity } = this.state; const wrapperStyles = [styles.container, { opacity }, this.props.style]; const { mediaInfo, pendingUpload, postInProgress } = this.props; return ( ); } onLayout = () => {}; viewRef = (view: ?React.ElementRef) => { this.view = view; }; onPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.clickable) { return; } this.clickable = false; const overlayContext = MultimediaMessageMultimedia.getOverlayContext( this.props, ); overlayContext.setScrollBlockingModalStatus('open'); const { mediaInfo, item } = this.props; view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigation.navigate({ name: MultimediaModalRouteName, key: MultimediaMessageMultimedia.getStableKey(this.props), params: { presentedFrom: this.props.route.key, mediaInfo, item, initialCoordinates: coordinates, verticalBounds, }, }); }); }; onLongPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.clickable) { return; } this.clickable = false; const { messageFocused, toggleMessageFocus, item, mediaInfo, verticalOffset, } = this.props; if (!messageFocused) { toggleMessageFocus(messageKey(item.messageInfo)); } const overlayContext = MultimediaMessageMultimedia.getOverlayContext( this.props, ); overlayContext.setScrollBlockingModalStatus('open'); view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const multimediaTop = pageY; const multimediaBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = multimediaTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const directlyAboveMargin = isViewer ? 30 : 50; const aboveMargin = verticalOffset === 0 ? directlyAboveMargin : 20; const aboveSpace = multimediaTooltipHeight + aboveMargin; let location = 'below', margin = belowMargin; if ( multimediaBottom + belowSpace > boundsBottom && multimediaTop - aboveSpace > boundsTop ) { location = 'above'; margin = aboveMargin; } this.props.navigation.navigate({ name: MultimediaTooltipModalRouteName, params: { presentedFrom: this.props.route.key, mediaInfo, item, initialCoordinates: coordinates, verticalOffset, verticalBounds, location, margin, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const styles = StyleSheet.create({ container: { flex: 1, overflow: 'hidden', }, expand: { flex: 1, }, }); export default React.memo( function ConnectedMultimediaMessageMultimedia(props: BaseProps) { const colors = useColors(); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); return ( ); }, ); diff --git a/native/chat/multimedia-message.react.js b/native/chat/multimedia-message.react.js index 40b00fffd..d0a7ae4be 100644 --- a/native/chat/multimedia-message.react.js +++ b/native/chat/multimedia-message.react.js @@ -1,307 +1,289 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { StyleSheet, View } from 'react-native'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; import type { Media, Corners } from 'lib/types/media-types'; import type { MultimediaMessageInfo, LocalMessageInfo, } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import type { MessagePendingUploads } from '../input/input-state'; import type { NavigationRoute } from '../navigation/route-names'; -import { - type VerticalBounds, - verticalBoundsPropType, -} from '../types/layout-types'; +import { type VerticalBounds } from '../types/layout-types'; import type { ViewStyle } from '../types/styles'; import type { ChatNavigationProp } from './chat.react'; import { ComposedMessage, clusterEndHeight } from './composed-message.react'; import { failedSendHeight } from './failed-send.react'; import { authorNameHeight } from './message-header.react'; -import { - messageListRoutePropType, - messageListNavPropType, -} from './message-list-types'; import MultimediaMessageMultimedia from './multimedia-message-multimedia.react'; import sendFailed from './multimedia-message-send-failed'; import { allCorners, filterCorners, getRoundedContainerStyle, } from './rounded-corners'; type ContentSizes = {| imageHeight: number, contentHeight: number, contentWidth: number, |}; export type ChatMultimediaMessageInfoItem = {| ...ContentSizes, itemType: 'message', messageShapeType: 'multimedia', messageInfo: MultimediaMessageInfo, localMessageInfo: ?LocalMessageInfo, threadInfo: ThreadInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, pendingUploads: ?MessagePendingUploads, |}; function getMediaPerRow(mediaCount: number) { if (mediaCount === 0) { return 0; // ??? } else if (mediaCount === 1) { return 1; } else if (mediaCount === 2) { return 2; } else if (mediaCount === 3) { return 3; } else if (mediaCount === 4) { return 2; } else { return 3; } } // Called by MessageListContainer // The results are merged into ChatMultimediaMessageInfoItem function multimediaMessageContentSizes( messageInfo: MultimediaMessageInfo, composedMessageMaxWidth: number, ): ContentSizes { invariant(messageInfo.media.length > 0, 'should have media'); if (messageInfo.media.length === 1) { const [media] = messageInfo.media; const { height, width } = media.dimensions; let imageHeight = height; if (width > composedMessageMaxWidth) { imageHeight = (height * composedMessageMaxWidth) / width; } if (imageHeight < 50) { imageHeight = 50; } let contentWidth = height ? (width * imageHeight) / height : 0; if (contentWidth > composedMessageMaxWidth) { contentWidth = composedMessageMaxWidth; } return { imageHeight, contentHeight: imageHeight, contentWidth }; } const contentWidth = composedMessageMaxWidth; const mediaPerRow = getMediaPerRow(messageInfo.media.length); const marginSpace = spaceBetweenImages * (mediaPerRow - 1); const imageHeight = (contentWidth - marginSpace) / mediaPerRow; const numRows = Math.ceil(messageInfo.media.length / mediaPerRow); const contentHeight = numRows * imageHeight + (numRows - 1) * spaceBetweenImages; return { imageHeight, contentHeight, contentWidth }; } // Called by Message // Given a ChatMultimediaMessageInfoItem, determines exact height of row function multimediaMessageItemHeight(item: ChatMultimediaMessageInfoItem) { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { creator } = messageInfo; const { isViewer } = creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (sendFailed(item)) { height += failedSendHeight; } return height; } const borderRadius = 16; type Props = {| ...React.ElementConfig, item: ChatMultimediaMessageInfoItem, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, focused: boolean, toggleFocus: (messageKey: string) => void, verticalBounds: ?VerticalBounds, |}; class MultimediaMessage extends React.PureComponent { - static propTypes = { - item: chatMessageItemPropType.isRequired, - navigation: messageListNavPropType.isRequired, - route: messageListRoutePropType.isRequired, - focused: PropTypes.bool.isRequired, - toggleFocus: PropTypes.func.isRequired, - verticalBounds: verticalBoundsPropType, - }; - render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, ...viewProps } = this.props; const containerStyle = { height: item.contentHeight, width: item.contentWidth, }; return ( {this.renderContent()} ); } renderContent(): React.Node { const { messageInfo, imageHeight } = this.props.item; invariant(messageInfo.media.length > 0, 'should have media'); if (messageInfo.media.length === 1) { return this.renderImage(messageInfo.media[0], 0, 0, allCorners); } const mediaPerRow = getMediaPerRow(messageInfo.media.length); const rowHeight = imageHeight + spaceBetweenImages; const rows = []; for ( let i = 0, verticalOffset = 0; i < messageInfo.media.length; i += mediaPerRow, verticalOffset += rowHeight ) { const rowMedia = []; for (let j = i; j < i + mediaPerRow; j++) { rowMedia.push(messageInfo.media[j]); } const firstRow = i === 0; const lastRow = i + mediaPerRow >= messageInfo.media.length; const row = []; let j = 0; for (; j < rowMedia.length; j++) { const media = rowMedia[j]; const firstInRow = j === 0; const lastInRow = j + 1 === rowMedia.length; const inLastColumn = j + 1 === mediaPerRow; const corners = { topLeft: firstRow && firstInRow, topRight: firstRow && inLastColumn, bottomLeft: lastRow && firstInRow, bottomRight: lastRow && inLastColumn, }; const style = lastInRow ? null : styles.imageBeforeImage; row.push( this.renderImage(media, i + j, verticalOffset, corners, style), ); } for (; j < mediaPerRow; j++) { const key = `filler${j}`; const style = j + 1 < mediaPerRow ? [styles.filler, styles.imageBeforeImage] : styles.filler; row.push(); } const rowStyle = lastRow ? styles.row : [styles.row, styles.rowAboveRow]; rows.push( {row} , ); } return {rows}; } renderImage( media: Media, index: number, verticalOffset: number, corners: Corners, style?: ViewStyle, ): React.Node { const filteredCorners = filterCorners(corners, this.props.item); const roundedStyle = getRoundedContainerStyle( filteredCorners, borderRadius, ); const { pendingUploads } = this.props.item; const mediaInfo = { ...media, corners: filteredCorners, index, }; const pendingUpload = pendingUploads && pendingUploads[media.id]; return ( ); } } const spaceBetweenImages = 4; const styles = StyleSheet.create({ filler: { flex: 1, }, grid: { flex: 1, justifyContent: 'space-between', }, imageBeforeImage: { marginRight: spaceBetweenImages, }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, rowAboveRow: { marginBottom: spaceBetweenImages, }, }); export { borderRadius as multimediaMessageBorderRadius, MultimediaMessage, multimediaMessageContentSizes, multimediaMessageItemHeight, sendFailed as multimediaMessageSendFailed, }; diff --git a/native/chat/multimedia-tooltip-button.react.js b/native/chat/multimedia-tooltip-button.react.js index e454ac75d..358616de4 100644 --- a/native/chat/multimedia-tooltip-button.react.js +++ b/native/chat/multimedia-tooltip-button.react.js @@ -1,139 +1,108 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import Animated from 'react-native-reanimated'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; import { messageID } from 'lib/shared/message-utils'; -import { mediaInfoPropType } from 'lib/types/media-types'; -import { - type InputState, - inputStatePropType, - InputStateContext, -} from '../input/input-state'; +import { type InputState, InputStateContext } from '../input/input-state'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import type { TooltipRoute } from '../navigation/tooltip.react'; import { useSelector } from '../redux/redux-utils'; -import { - verticalBoundsPropType, - layoutCoordinatesPropType, -} from '../types/layout-types'; import InlineMultimedia from './inline-multimedia.react'; import { MessageHeader } from './message-header.react'; import { multimediaMessageBorderRadius } from './multimedia-message.react'; import { getRoundedContainerStyle } from './rounded-corners'; /* eslint-disable import/no-named-as-default-member */ const { Value } = Animated; /* eslint-enable import/no-named-as-default-member */ type BaseProps = {| +navigation: AppNavigationProp<'MultimediaTooltipModal'>, +route: TooltipRoute<'MultimediaTooltipModal'>, +progress: Value, |}; type Props = {| ...BaseProps, // Redux state +windowWidth: number, // withInputState +inputState: ?InputState, |}; class MultimediaTooltipButton extends React.PureComponent { - static propTypes = { - navigation: PropTypes.shape({ - goBackOnce: PropTypes.func.isRequired, - }).isRequired, - route: PropTypes.shape({ - params: PropTypes.shape({ - initialCoordinates: layoutCoordinatesPropType.isRequired, - verticalBounds: verticalBoundsPropType.isRequired, - location: PropTypes.oneOf(['above', 'below']), - margin: PropTypes.number, - item: chatMessageItemPropType.isRequired, - mediaInfo: mediaInfoPropType.isRequired, - verticalOffset: PropTypes.number.isRequired, - }).isRequired, - }).isRequired, - progress: PropTypes.object.isRequired, - windowWidth: PropTypes.number.isRequired, - inputState: inputStatePropType, - }; - get headerStyle() { const { initialCoordinates, verticalOffset } = this.props.route.params; const bottom = initialCoordinates.height + verticalOffset; return { opacity: this.props.progress, position: 'absolute', left: -initialCoordinates.x, width: this.props.windowWidth, bottom, }; } render() { const { inputState } = this.props; const { mediaInfo, item } = this.props.route.params; const { id: mediaID } = mediaInfo; const ourMessageID = messageID(item.messageInfo); const pendingUploads = inputState && inputState.pendingUploads && inputState.pendingUploads[ourMessageID]; const pendingUpload = pendingUploads && pendingUploads[mediaID]; const postInProgress = !!pendingUploads; const roundedStyle = getRoundedContainerStyle( mediaInfo.corners, multimediaMessageBorderRadius, ); return ( ); } onPress = () => { this.props.navigation.goBackOnce(); }; } const styles = StyleSheet.create({ media: { flex: 1, overflow: 'hidden', }, }); export default React.memo(function ConnectedMultimediaTooltipButton( props: BaseProps, ) { const windowWidth = useSelector((state) => state.dimensions.width); const inputState = React.useContext(InputStateContext); return ( ); }); diff --git a/native/media/multimedia-modal.react.js b/native/media/multimedia-modal.react.js index a8b1586f5..c4559b818 100644 --- a/native/media/multimedia-modal.react.js +++ b/native/media/multimedia-modal.react.js @@ -1,1263 +1,1238 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, } from 'react-native'; import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import Animated from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/Ionicons'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; -import { - type MediaInfo, - mediaInfoPropType, - type Dimensions, -} from 'lib/types/media-types'; +import { type MediaInfo, type Dimensions } from 'lib/types/media-types'; import type { ChatMultimediaMessageInfoItem } from '../chat/multimedia-message.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext, type OverlayContextType, - overlayContextPropType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type DerivedDimensionsInfo, - derivedDimensionsInfoPropType, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors'; import { type VerticalBounds, - verticalBoundsPropType, type LayoutCoordinates, - layoutCoordinatesPropType, } from '../types/layout-types'; import type { NativeMethods } from '../types/react-native'; import { clamp, gestureJustStarted, gestureJustEnded, runTiming, } from '../utils/animation-utils'; import Multimedia from './multimedia.react'; import { intentionalSaveMedia } from './save-media'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, event, Extrapolate, set, call, cond, not, and, or, eq, neq, greaterThan, lessThan, add, sub, multiply, divide, pow, max, min, round, abs, interpolate, startClock, stopClock, clockRunning, decay, } = Animated; /* eslint-enable import/no-named-as-default-member */ function scaleDelta(value: Value, gestureActive: Value) { const diffThisFrame = new Value(1); const prevValue = new Value(1); return cond( gestureActive, [ set(diffThisFrame, divide(value, prevValue)), set(prevValue, value), diffThisFrame, ], set(prevValue, 1), ); } function panDelta(value: Value, gestureActive: Value) { const diffThisFrame = new Value(0); const prevValue = new Value(0); return cond( gestureActive, [ set(diffThisFrame, sub(value, prevValue)), set(prevValue, value), diffThisFrame, ], set(prevValue, 0), ); } function runDecay( clock: Clock, velocity: Value, initialPosition: Value, startStopClock: boolean = true, ): Value { const state = { finished: new Value(0), velocity: new Value(0), position: new Value(0), time: new Value(0), }; const config = { deceleration: 0.99 }; return [ cond(not(clockRunning(clock)), [ set(state.finished, 0), set(state.velocity, velocity), set(state.position, initialPosition), set(state.time, 0), startStopClock && startClock(clock), ]), decay(clock, state, config), cond(state.finished, startStopClock && stopClock(clock)), state.position, ]; } export type MultimediaModalParams = {| presentedFrom: string, mediaInfo: MediaInfo, initialCoordinates: LayoutCoordinates, verticalBounds: VerticalBounds, item: ChatMultimediaMessageInfoItem, |}; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; type BaseProps = {| +navigation: AppNavigationProp<'MultimediaModal'>, +route: NavigationRoute<'MultimediaModal'>, |}; type Props = {| ...BaseProps, // Redux state +dimensions: DerivedDimensionsInfo, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +closeButtonEnabled: boolean, +actionLinksEnabled: boolean, |}; class MultimediaModal extends React.PureComponent { - static propTypes = { - navigation: PropTypes.shape({ - goBackOnce: PropTypes.func.isRequired, - }).isRequired, - route: PropTypes.shape({ - params: PropTypes.shape({ - mediaInfo: mediaInfoPropType.isRequired, - initialCoordinates: layoutCoordinatesPropType.isRequired, - verticalBounds: verticalBoundsPropType.isRequired, - item: chatMessageItemPropType.isRequired, - }).isRequired, - }).isRequired, - dimensions: derivedDimensionsInfoPropType.isRequired, - overlayContext: overlayContextPropType, - }; state: State = { closeButtonEnabled: true, actionLinksEnabled: true, }; closeButton: ?React.ElementRef; saveButton: ?React.ElementRef; closeButtonX = new Value(-1); closeButtonY = new Value(-1); closeButtonWidth = new Value(0); closeButtonHeight = new Value(0); closeButtonLastState = new Value(1); saveButtonX = new Value(-1); saveButtonY = new Value(-1); saveButtonWidth = new Value(0); saveButtonHeight = new Value(0); actionLinksLastState = new Value(1); centerX: Value; centerY: Value; frameWidth: Value; frameHeight: Value; imageWidth: Value; imageHeight: Value; pinchHandler = React.createRef(); panHandler = React.createRef(); singleTapHandler = React.createRef(); doubleTapHandler = React.createRef(); handlerRefs = [ this.pinchHandler, this.panHandler, this.singleTapHandler, this.doubleTapHandler, ]; beforeDoubleTapRefs; beforeSingleTapRefs; pinchEvent; panEvent; singleTapEvent; doubleTapEvent; scale: Value; x: Value; y: Value; backdropOpacity: Value; imageContainerOpacity: Value; actionLinksOpacity: Value; closeButtonOpacity: Value; constructor(props: Props) { super(props); this.updateDimensions(); const { imageWidth, imageHeight } = this; const left = sub(this.centerX, divide(imageWidth, 2)); const top = sub(this.centerY, divide(imageHeight, 2)); const { initialCoordinates } = props.route.params; const initialScale = divide(initialCoordinates.width, imageWidth); const initialTranslateX = sub( initialCoordinates.x + initialCoordinates.width / 2, add(left, divide(imageWidth, 2)), ); const initialTranslateY = sub( initialCoordinates.y + initialCoordinates.height / 2, add(top, divide(imageHeight, 2)), ); const { overlayContext } = props; invariant(overlayContext, 'MultimediaModal should have OverlayContext'); const navigationProgress = overlayContext.position; // The inputs we receive from PanGestureHandler const panState = new Value(-1); const panTranslationX = new Value(0); const panTranslationY = new Value(0); const panVelocityX = new Value(0); const panVelocityY = new Value(0); const panAbsoluteX = new Value(0); const panAbsoluteY = new Value(0); this.panEvent = event([ { nativeEvent: { state: panState, translationX: panTranslationX, translationY: panTranslationY, velocityX: panVelocityX, velocityY: panVelocityY, absoluteX: panAbsoluteX, absoluteY: panAbsoluteY, }, }, ]); const curPanActive = new Value(0); const panActive = [ cond( and( gestureJustStarted(panState), this.outsideButtons( sub(panAbsoluteX, panTranslationX), sub(panAbsoluteY, panTranslationY), ), ), set(curPanActive, 1), ), cond(gestureJustEnded(panState), set(curPanActive, 0)), curPanActive, ]; const lastPanActive = new Value(0); const panJustEnded = cond(eq(lastPanActive, panActive), 0, [ set(lastPanActive, panActive), eq(panActive, 0), ]); // The inputs we receive from PinchGestureHandler const pinchState = new Value(-1); const pinchScale = new Value(1); const pinchFocalX = new Value(0); const pinchFocalY = new Value(0); this.pinchEvent = event([ { nativeEvent: { state: pinchState, scale: pinchScale, focalX: pinchFocalX, focalY: pinchFocalY, }, }, ]); const pinchActive = eq(pinchState, GestureState.ACTIVE); // The inputs we receive from single TapGestureHandler const singleTapState = new Value(-1); const singleTapX = new Value(0); const singleTapY = new Value(0); this.singleTapEvent = event([ { nativeEvent: { state: singleTapState, x: singleTapX, y: singleTapY, }, }, ]); // The inputs we receive from double TapGestureHandler const doubleTapState = new Value(-1); const doubleTapX = new Value(0); const doubleTapY = new Value(0); this.doubleTapEvent = event([ { nativeEvent: { state: doubleTapState, x: doubleTapX, y: doubleTapY, }, }, ]); // The all-important outputs const curScale = new Value(1); const curX = new Value(0); const curY = new Value(0); const curBackdropOpacity = new Value(1); const curCloseButtonOpacity = new Value(1); const curActionLinksOpacity = new Value(1); // The centered variables help us know if we need to be recentered const recenteredScale = max(curScale, 1); const horizontalPanSpace = this.horizontalPanSpace(recenteredScale); const verticalPanSpace = this.verticalPanSpace(recenteredScale); const resetXClock = new Clock(); const resetYClock = new Clock(); const zoomClock = new Clock(); const dismissingFromPan = new Value(0); const roundedCurScale = divide(round(multiply(curScale, 1000)), 1000); const gestureActive = or(pinchActive, panActive); const activeInteraction = or( gestureActive, clockRunning(zoomClock), dismissingFromPan, ); const updates = [ this.pinchUpdate( pinchActive, pinchScale, pinchFocalX, pinchFocalY, curScale, curX, curY, ), this.panUpdate(panActive, panTranslationX, panTranslationY, curX, curY), this.singleTapUpdate( singleTapState, singleTapX, singleTapY, roundedCurScale, curCloseButtonOpacity, curActionLinksOpacity, ), this.doubleTapUpdate( doubleTapState, doubleTapX, doubleTapY, roundedCurScale, zoomClock, gestureActive, curScale, curX, curY, ), this.backdropOpacityUpdate( panJustEnded, pinchActive, panVelocityX, panVelocityY, curX, curY, roundedCurScale, curBackdropOpacity, dismissingFromPan, ), this.recenter( resetXClock, resetYClock, activeInteraction, recenteredScale, horizontalPanSpace, verticalPanSpace, curScale, curX, curY, ), this.flingUpdate( resetXClock, resetYClock, activeInteraction, panJustEnded, panVelocityX, panVelocityY, horizontalPanSpace, verticalPanSpace, curX, curY, ), ]; const updatedScale = [updates, curScale]; const updatedCurX = [updates, curX]; const updatedCurY = [updates, curY]; const updatedBackdropOpacity = [updates, curBackdropOpacity]; const updatedCloseButtonOpacity = [updates, curCloseButtonOpacity]; const updatedActionLinksOpacity = [updates, curActionLinksOpacity]; const reverseNavigationProgress = sub(1, navigationProgress); this.scale = add( multiply(reverseNavigationProgress, initialScale), multiply(navigationProgress, updatedScale), ); this.x = add( multiply(reverseNavigationProgress, initialTranslateX), multiply(navigationProgress, updatedCurX), ); this.y = add( multiply(reverseNavigationProgress, initialTranslateY), multiply(navigationProgress, updatedCurY), ); this.backdropOpacity = multiply(navigationProgress, updatedBackdropOpacity); this.imageContainerOpacity = interpolate(navigationProgress, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const buttonOpacity = interpolate(updatedBackdropOpacity, { inputRange: [0.95, 1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); this.closeButtonOpacity = multiply( navigationProgress, buttonOpacity, updatedCloseButtonOpacity, ); this.actionLinksOpacity = multiply( navigationProgress, buttonOpacity, updatedActionLinksOpacity, ); this.beforeDoubleTapRefs = Platform.select({ android: [], default: [this.pinchHandler, this.panHandler], }); this.beforeSingleTapRefs = [ ...this.beforeDoubleTapRefs, this.doubleTapHandler, ]; } // How much space do we have to pan the image horizontally? horizontalPanSpace(scale: Value) { const apparentWidth = multiply(this.imageWidth, scale); const horizPop = divide(sub(apparentWidth, this.frameWidth), 2); return max(horizPop, 0); } // How much space do we have to pan the image vertically? verticalPanSpace(scale: Value) { const apparentHeight = multiply(this.imageHeight, scale); const vertPop = divide(sub(apparentHeight, this.frameHeight), 2); return max(vertPop, 0); } pinchUpdate( // Inputs pinchActive: Value, pinchScale: Value, pinchFocalX: Value, pinchFocalY: Value, // Outputs curScale: Value, curX: Value, curY: Value, ): Value { const deltaScale = scaleDelta(pinchScale, pinchActive); const deltaPinchX = multiply( sub(1, deltaScale), sub(pinchFocalX, curX, this.centerX), ); const deltaPinchY = multiply( sub(1, deltaScale), sub(pinchFocalY, curY, this.centerY), ); return cond( [deltaScale, pinchActive], [ set(curX, add(curX, deltaPinchX)), set(curY, add(curY, deltaPinchY)), set(curScale, multiply(curScale, deltaScale)), ], ); } outsideButtons(x: Value, y: Value) { const { closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, closeButtonLastState, saveButtonX, saveButtonY, saveButtonWidth, saveButtonHeight, actionLinksLastState, } = this; return and( or( eq(closeButtonLastState, 0), lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( eq(actionLinksLastState, 0), lessThan(x, saveButtonX), greaterThan(x, add(saveButtonX, saveButtonWidth)), lessThan(y, saveButtonY), greaterThan(y, add(saveButtonY, saveButtonHeight)), ), ); } panUpdate( // Inputs panActive: Value, panTranslationX: Value, panTranslationY: Value, // Outputs curX: Value, curY: Value, ): Value { const deltaX = panDelta(panTranslationX, panActive); const deltaY = panDelta(panTranslationY, panActive); return cond( [deltaX, deltaY, panActive], [set(curX, add(curX, deltaX)), set(curY, add(curY, deltaY))], ); } singleTapUpdate( // Inputs singleTapState: Value, singleTapX: Value, singleTapY: Value, roundedCurScale: Value, // Outputs curCloseButtonOpacity: Value, curActionLinksOpacity: Value, ): Value { const lastTapX = new Value(0); const lastTapY = new Value(0); const fingerJustReleased = and( gestureJustEnded(singleTapState), this.outsideButtons(lastTapX, lastTapY), ); const wasZoomed = new Value(0); const isZoomed = greaterThan(roundedCurScale, 1); const becameUnzoomed = and(wasZoomed, not(isZoomed)); const closeButtonState = cond( or( fingerJustReleased, and(becameUnzoomed, eq(this.closeButtonLastState, 0)), ), sub(1, this.closeButtonLastState), this.closeButtonLastState, ); const actionLinksState = cond( isZoomed, 0, cond( or(fingerJustReleased, becameUnzoomed), sub(1, this.actionLinksLastState), this.actionLinksLastState, ), ); const closeButtonAppearClock = new Clock(); const closeButtonDisappearClock = new Clock(); const actionLinksAppearClock = new Clock(); const actionLinksDisappearClock = new Clock(); return [ fingerJustReleased, set( curCloseButtonOpacity, cond( eq(closeButtonState, 1), [ stopClock(closeButtonDisappearClock), runTiming(closeButtonAppearClock, curCloseButtonOpacity, 1), ], [ stopClock(closeButtonAppearClock), runTiming(closeButtonDisappearClock, curCloseButtonOpacity, 0), ], ), ), set( curActionLinksOpacity, cond( eq(actionLinksState, 1), [ stopClock(actionLinksDisappearClock), runTiming(actionLinksAppearClock, curActionLinksOpacity, 1), ], [ stopClock(actionLinksAppearClock), runTiming(actionLinksDisappearClock, curActionLinksOpacity, 0), ], ), ), set(this.actionLinksLastState, actionLinksState), set(this.closeButtonLastState, closeButtonState), set(wasZoomed, isZoomed), set(lastTapX, singleTapX), set(lastTapY, singleTapY), call([eq(curCloseButtonOpacity, 1)], this.setCloseButtonEnabled), call([eq(curActionLinksOpacity, 1)], this.setActionLinksEnabled), ]; } doubleTapUpdate( // Inputs doubleTapState: Value, doubleTapX: Value, doubleTapY: Value, roundedCurScale: Value, zoomClock: Clock, gestureActive: Value, // Outputs curScale: Value, curX: Value, curY: Value, ): Value { const zoomClockRunning = clockRunning(zoomClock); const zoomActive = and(not(gestureActive), zoomClockRunning); const targetScale = cond(greaterThan(roundedCurScale, 1), 1, 3); const tapXDiff = sub(doubleTapX, this.centerX, curX); const tapYDiff = sub(doubleTapY, this.centerY, curY); const tapXPercent = divide(tapXDiff, this.imageWidth, curScale); const tapYPercent = divide(tapYDiff, this.imageHeight, curScale); const horizPanSpace = this.horizontalPanSpace(targetScale); const vertPanSpace = this.verticalPanSpace(targetScale); const horizPanPercent = divide(horizPanSpace, this.imageWidth, targetScale); const vertPanPercent = divide(vertPanSpace, this.imageHeight, targetScale); const tapXPercentClamped = clamp( tapXPercent, multiply(-1, horizPanPercent), horizPanPercent, ); const tapYPercentClamped = clamp( tapYPercent, multiply(-1, vertPanPercent), vertPanPercent, ); const targetX = multiply(tapXPercentClamped, this.imageWidth, targetScale); const targetY = multiply(tapYPercentClamped, this.imageHeight, targetScale); const targetRelativeScale = divide(targetScale, curScale); const targetRelativeX = multiply(-1, add(targetX, curX)); const targetRelativeY = multiply(-1, add(targetY, curY)); const zoomScale = runTiming(zoomClock, 1, targetRelativeScale); const zoomX = runTiming(zoomClock, 0, targetRelativeX, false); const zoomY = runTiming(zoomClock, 0, targetRelativeY, false); const deltaScale = scaleDelta(zoomScale, zoomActive); const deltaX = panDelta(zoomX, zoomActive); const deltaY = panDelta(zoomY, zoomActive); const fingerJustReleased = and( gestureJustEnded(doubleTapState), this.outsideButtons(doubleTapX, doubleTapY), ); return cond( [fingerJustReleased, deltaX, deltaY, deltaScale, gestureActive], stopClock(zoomClock), cond(or(zoomClockRunning, fingerJustReleased), [ zoomX, zoomY, zoomScale, set(curX, add(curX, deltaX)), set(curY, add(curY, deltaY)), set(curScale, multiply(curScale, deltaScale)), ]), ); } backdropOpacityUpdate( // Inputs panJustEnded: Value, pinchActive: Value, panVelocityX: Value, panVelocityY: Value, curX: Value, curY: Value, roundedCurScale: Value, // Outputs curBackdropOpacity: Value, dismissingFromPan: Value, ): Value { const progressiveOpacity = max( min( sub(1, abs(divide(curX, this.frameWidth))), sub(1, abs(divide(curY, this.frameHeight))), ), 0, ); const resetClock = new Clock(); const velocity = pow(add(pow(panVelocityX, 2), pow(panVelocityY, 2)), 0.5); const shouldGoBack = and( panJustEnded, or(greaterThan(velocity, 50), greaterThan(0.7, progressiveOpacity)), ); const decayClock = new Clock(); const decayItems = [ set(curX, runDecay(decayClock, panVelocityX, curX, false)), set(curY, runDecay(decayClock, panVelocityY, curY)), ]; return cond( [panJustEnded, dismissingFromPan], decayItems, cond( or(pinchActive, greaterThan(roundedCurScale, 1)), set(curBackdropOpacity, runTiming(resetClock, curBackdropOpacity, 1)), [ stopClock(resetClock), set(curBackdropOpacity, progressiveOpacity), set(dismissingFromPan, shouldGoBack), cond(shouldGoBack, [decayItems, call([], this.close)]), ], ), ); } recenter( // Inputs resetXClock: Clock, resetYClock: Clock, activeInteraction: Value, recenteredScale: Value, horizontalPanSpace: Value, verticalPanSpace: Value, // Outputs curScale: Value, curX: Value, curY: Value, ): Value { const resetScaleClock = new Clock(); const recenteredX = clamp( curX, multiply(-1, horizontalPanSpace), horizontalPanSpace, ); const recenteredY = clamp( curY, multiply(-1, verticalPanSpace), verticalPanSpace, ); return cond( activeInteraction, [ stopClock(resetScaleClock), stopClock(resetXClock), stopClock(resetYClock), ], [ cond( or(clockRunning(resetScaleClock), neq(recenteredScale, curScale)), set(curScale, runTiming(resetScaleClock, curScale, recenteredScale)), ), cond( or(clockRunning(resetXClock), neq(recenteredX, curX)), set(curX, runTiming(resetXClock, curX, recenteredX)), ), cond( or(clockRunning(resetYClock), neq(recenteredY, curY)), set(curY, runTiming(resetYClock, curY, recenteredY)), ), ], ); } flingUpdate( // Inputs resetXClock: Clock, resetYClock: Clock, activeInteraction: Value, panJustEnded: Value, panVelocityX: Value, panVelocityY: Value, horizontalPanSpace: Value, verticalPanSpace: Value, // Outputs curX: Value, curY: Value, ): Value { const flingXClock = new Clock(); const flingYClock = new Clock(); const decayX = runDecay(flingXClock, panVelocityX, curX); const recenteredX = clamp( decayX, multiply(-1, horizontalPanSpace), horizontalPanSpace, ); const decayY = runDecay(flingYClock, panVelocityY, curY); const recenteredY = clamp( decayY, multiply(-1, verticalPanSpace), verticalPanSpace, ); return cond( activeInteraction, [stopClock(flingXClock), stopClock(flingYClock)], [ cond( clockRunning(resetXClock), stopClock(flingXClock), cond(or(panJustEnded, clockRunning(flingXClock)), [ set(curX, recenteredX), cond(neq(decayX, recenteredX), stopClock(flingXClock)), ]), ), cond( clockRunning(resetYClock), stopClock(flingYClock), cond(or(panJustEnded, clockRunning(flingYClock)), [ set(curY, recenteredY), cond(neq(decayY, recenteredY), stopClock(flingYClock)), ]), ), ], ); } updateDimensions() { const { width: frameWidth, height: frameHeight } = this.frame; const { topInset } = this.props.dimensions; if (this.frameWidth) { this.frameWidth.setValue(frameWidth); } else { this.frameWidth = new Value(frameWidth); } if (this.frameHeight) { this.frameHeight.setValue(frameHeight); } else { this.frameHeight = new Value(frameHeight); } const centerX = frameWidth / 2; const centerY = frameHeight / 2 + topInset; if (this.centerX) { this.centerX.setValue(centerX); } else { this.centerX = new Value(centerX); } if (this.centerY) { this.centerY.setValue(centerY); } else { this.centerY = new Value(centerY); } const { width, height } = this.imageDimensions; if (this.imageWidth) { this.imageWidth.setValue(width); } else { this.imageWidth = new Value(width); } if (this.imageHeight) { this.imageHeight.setValue(height); } else { this.imageHeight = new Value(height); } } componentDidMount() { if (MultimediaModal.isActive(this.props)) { Orientation.unlockAllOrientations(); } } componentWillUnmount() { if (MultimediaModal.isActive(this.props)) { Orientation.lockToPortrait(); } } componentDidUpdate(prevProps: Props) { if (this.props.dimensions !== prevProps.dimensions) { this.updateDimensions(); } const isActive = MultimediaModal.isActive(this.props); const wasActive = MultimediaModal.isActive(prevProps); if (isActive && !wasActive) { Orientation.unlockAllOrientations(); } else if (!isActive && wasActive) { Orientation.lockToPortrait(); } } get frame(): Dimensions { const { width, safeAreaHeight } = this.props.dimensions; return { width, height: safeAreaHeight }; } get imageDimensions(): Dimensions { // Make space for the close button let { height: maxHeight, width: maxWidth } = this.frame; if (maxHeight > maxWidth) { maxHeight -= 100; } else { maxWidth -= 100; } const { dimensions } = this.props.route.params.mediaInfo; if (dimensions.height < maxHeight && dimensions.width < maxWidth) { return dimensions; } const heightRatio = maxHeight / dimensions.height; const widthRatio = maxWidth / dimensions.width; if (heightRatio < widthRatio) { return { height: maxHeight, width: dimensions.width * heightRatio, }; } else { return { width: maxWidth, height: dimensions.height * widthRatio, }; } } get imageContainerStyle() { const { height, width } = this.imageDimensions; const { height: frameHeight, width: frameWidth } = this.frame; const top = (frameHeight - height) / 2 + this.props.dimensions.topInset; const left = (frameWidth - width) / 2; const { verticalBounds } = this.props.route.params; return { height, width, marginTop: top - verticalBounds.y, marginLeft: left, opacity: this.imageContainerOpacity, transform: [ { translateX: this.x }, { translateY: this.y }, { scale: this.scale }, ], }; } static isActive(props) { const { overlayContext } = props; invariant(overlayContext, 'MultimediaModal should have OverlayContext'); return !overlayContext.isDismissing; } get contentContainerStyle() { const { verticalBounds } = this.props.route.params; const fullScreenHeight = this.props.dimensions.height; const top = verticalBounds.y; const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height; // margin will clip, but padding won't const verticalStyle = MultimediaModal.isActive(this.props) ? { paddingTop: top, paddingBottom: bottom } : { marginTop: top, marginBottom: bottom }; return [styles.contentContainer, verticalStyle]; } render() { const { mediaInfo } = this.props.route.params; const statusBar = MultimediaModal.isActive(this.props) ? (